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]
current_version = 2021.12.3
current_version = 2021.12.5
tag = True
commit = True
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-?(?P<release>.*)
@ -17,7 +17,7 @@ values =
beta
stable
[bumpversion:file:website/docs/installation/docker-compose.md]
[bumpversion:file:pyproject.toml]
[bumpversion:file:docker-compose.yml]
@ -30,7 +30,3 @@ values =
[bumpversion:file:internal/constants/constants.go]
[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:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
with:
python-version: '3.9'
- uses: actions/setup-node@v2
with:
node-version: '16'
- id: cache-pipenv
- id: cache-poetry
uses: actions/cache@v2.1.7
with:
path: ~/.local/share/virtualenvs
key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }}
path: ~/.cache/pypoetry/virtualenvs
key: ${{ runner.os }}-poetry-cache-v3-${{ hashFiles('**/poetry.lock') }}
- name: prepare
env:
INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }}
INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
run: scripts/ci_prepare.sh
- name: run job
run: pipenv run make ci-${{ matrix.job }}
run: poetry run make ci-${{ matrix.job }}
test-migrations:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
with:
python-version: '3.9'
- id: cache-pipenv
- id: cache-poetry
uses: actions/cache@v2.1.7
with:
path: ~/.local/share/virtualenvs
key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }}
path: ~/.cache/pypoetry/virtualenvs
key: ${{ runner.os }}-poetry-cache-v3-${{ hashFiles('**/poetry.lock') }}
- name: prepare
env:
INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }}
INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
run: scripts/ci_prepare.sh
- name: run migrations
run: pipenv run python -m lifecycle.migrate
run: poetry run python -m lifecycle.migrate
test-migrations-from-stable:
runs-on: ubuntu-latest
steps:
@ -74,75 +70,79 @@ jobs:
with:
fetch-depth: 0
- uses: actions/setup-python@v2
with:
python-version: '3.9'
- name: prepare variables
id: ev
run: |
python ./scripts/gh_env.py
- id: cache-pipenv
sudo pip install -U pipenv
- id: cache-poetry
uses: actions/cache@v2.1.7
with:
path: ~/.local/share/virtualenvs
key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }}
path: ~/.cache/pypoetry/virtualenvs
key: ${{ runner.os }}-poetry-cache-v3-${{ hashFiles('**/poetry.lock') }}
- name: checkout stable
id: stable
run: |
# Save current branch
current=$(git branch --show)
echo ##[set-output name=originalBranch]$current
# Copy current, latest config to local
cp authentik/lib/default.yml local.env.yml
cp -R .github ..
cp -R scripts ..
cp -R poetry.lock pyproject.toml ..
git checkout $(git describe --abbrev=0 --match 'version/*')
rm -rf .github/ scripts/
mv ../.github ../scripts .
mv ../.github ../scripts ../poetry.lock ../pyproject.toml .
- name: prepare
env:
INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }}
INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
run: |
scripts/ci_prepare.sh
# 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
run: pipenv run python -m lifecycle.migrate
run: poetry run python -m lifecycle.migrate
- name: checkout current code
run: |
set -x
git fetch
git reset --hard HEAD
git checkout ${{ steps.stable.outputs.originalBranch }}
pipenv sync --dev
# TODO: Remove after next stable release
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
env:
INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }}
INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
run: scripts/ci_prepare.sh
- name: migrate to latest
run: pipenv run python -m lifecycle.migrate
run: poetry run python -m lifecycle.migrate
test-unittest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
with:
python-version: '3.9'
- id: cache-pipenv
- id: cache-poetry
uses: actions/cache@v2.1.7
with:
path: ~/.local/share/virtualenvs
key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }}
path: ~/.cache/pypoetry/virtualenvs
key: ${{ runner.os }}-poetry-cache-v3-${{ hashFiles('**/poetry.lock') }}
- name: prepare
env:
INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }}
INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
run: scripts/ci_prepare.sh
- uses: testspace-com/setup-testspace@v1
with:
domain: ${{github.repository_owner}}
- name: run unittest
run: |
pipenv run make test
pipenv run coverage xml
poetry run make test
poetry run coverage xml
- name: run testspace
if: ${{ always() }}
run: |
@ -154,16 +154,14 @@ jobs:
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
with:
python-version: '3.9'
- id: cache-pipenv
- id: cache-poetry
uses: actions/cache@v2.1.7
with:
path: ~/.local/share/virtualenvs
key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }}
path: ~/.cache/pypoetry/virtualenvs
key: ${{ runner.os }}-poetry-cache-v3-${{ hashFiles('**/poetry.lock') }}
- name: prepare
env:
INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }}
INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
run: scripts/ci_prepare.sh
- uses: testspace-com/setup-testspace@v1
with:
@ -172,8 +170,8 @@ jobs:
uses: helm/kind-action@v1.2.0
- name: run integration
run: |
pipenv run make test-integration
pipenv run coverage xml
poetry run make test-integration
poetry run coverage xml
- name: run testspace
if: ${{ always() }}
run: |
@ -185,8 +183,6 @@ jobs:
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
with:
python-version: '3.9'
- uses: actions/setup-node@v2
with:
node-version: '16'
@ -195,14 +191,14 @@ jobs:
- uses: testspace-com/setup-testspace@v1
with:
domain: ${{github.repository_owner}}
- id: cache-pipenv
- id: cache-poetry
uses: actions/cache@v2.1.7
with:
path: ~/.local/share/virtualenvs
key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }}
path: ~/.cache/pypoetry/virtualenvs
key: ${{ runner.os }}-poetry-cache-v3-${{ hashFiles('**/poetry.lock') }}
- name: prepare
env:
INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }}
INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
run: |
scripts/ci_prepare.sh
docker-compose -f tests/e2e/docker-compose.yml up -d
@ -219,8 +215,8 @@ jobs:
npm run build
- name: run e2e
run: |
pipenv run make test-e2e-provider
pipenv run coverage xml
poetry run make test-e2e-provider
poetry run coverage xml
- name: run testspace
if: ${{ always() }}
run: |
@ -232,8 +228,6 @@ jobs:
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
with:
python-version: '3.9'
- uses: actions/setup-node@v2
with:
node-version: '16'
@ -242,14 +236,14 @@ jobs:
- uses: testspace-com/setup-testspace@v1
with:
domain: ${{github.repository_owner}}
- id: cache-pipenv
- id: cache-poetry
uses: actions/cache@v2.1.7
with:
path: ~/.local/share/virtualenvs
key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }}
path: ~/.cache/pypoetry/virtualenvs
key: ${{ runner.os }}-poetry-cache-v3-${{ hashFiles('**/poetry.lock') }}
- name: prepare
env:
INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }}
INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
run: |
scripts/ci_prepare.sh
docker-compose -f tests/e2e/docker-compose.yml up -d
@ -266,8 +260,8 @@ jobs:
npm run build
- name: run e2e
run: |
pipenv run make test-e2e-rest
pipenv run coverage xml
poetry run make test-e2e-rest
poetry run coverage xml
- name: run testspace
if: ${{ always() }}
run: |

View File

@ -30,14 +30,14 @@ jobs:
with:
push: ${{ github.event_name == 'release' }}
tags: |
beryju/authentik:2021.12.3,
beryju/authentik:2021.12.5,
beryju/authentik:latest,
ghcr.io/goauthentik/server:2021.12.3,
ghcr.io/goauthentik/server:2021.12.5,
ghcr.io/goauthentik/server:latest
platforms: linux/amd64,linux/arm64
context: .
- 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: |
docker pull beryju/authentik:latest
docker tag beryju/authentik:latest beryju/authentik:stable
@ -78,14 +78,14 @@ jobs:
with:
push: ${{ github.event_name == 'release' }}
tags: |
beryju/authentik-${{ matrix.type }}:2021.12.3,
beryju/authentik-${{ matrix.type }}:2021.12.5,
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
file: ${{ matrix.type }}.Dockerfile
platforms: linux/amd64,linux/arm64
- 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: |
docker pull beryju/authentik-${{ matrix.type }}:latest
docker tag beryju/authentik-${{ matrix.type }}:latest beryju/authentik-${{ matrix.type }}:stable
@ -170,7 +170,7 @@ jobs:
SENTRY_PROJECT: authentik
SENTRY_URL: https://sentry.beryju.org
with:
version: authentik@2021.12.3
version: authentik@2021.12.5
environment: beryjuorg-prod
sourcemaps: './web/dist'
url_prefix: '~/static/dist'

View File

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

View File

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

View File

@ -1,16 +1,4 @@
# Stage 1: Lock python dependencies
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
# Stage 1: Build website
FROM --platform=${BUILDPLATFORM} docker.io/node:16 as website-builder
COPY ./website /work/website/
@ -18,7 +6,7 @@ COPY ./website /work/website/
ENV NODE_ENV=production
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
COPY ./web /work/web/
@ -27,7 +15,7 @@ COPY ./website /work/website/
ENV NODE_ENV=production
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
WORKDIR /work
@ -43,29 +31,38 @@ COPY ./go.sum /work/go.sum
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
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 /
COPY --from=locker /app/requirements.txt /
COPY --from=locker /app/requirements-dev.txt /
ARG GIT_BUILD_HASH
ENV GIT_BUILD_HASH=$GIT_BUILD_HASH
COPY ./pyproject.toml /
COPY ./poetry.lock /
RUN apt-get update && \
apt-get install -y --no-install-recommends \
curl ca-certificates gnupg git runit libpq-dev \
postgresql-client build-essential libxmlsec1-dev \
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 autoremove --purge -y && \
apt-get clean && \
rm -rf /tmp/* /var/lib/apt/lists/* /var/tmp/ && \
adduser --system --no-create-home --uid 1000 --group --home /authentik authentik && \
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 ./pyproject.toml /

View File

@ -35,6 +35,7 @@ lint-fix:
lint:
bandit -r authentik tests lifecycle -x node_modules
pylint authentik tests lifecycle
golangci-lint run -v
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
# 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
ci-black:
ci-black: ci--meta-debug
black --check authentik tests lifecycle
ci-isort:
ci-isort: ci--meta-debug
isort --check authentik tests lifecycle
ci-bandit:
ci-bandit: ci--meta-debug
bandit -r authentik tests lifecycle
ci-pyright:
ci-pyright: ci--meta-debug
pyright e2e lifecycle
ci-pending-migrations:
ci-pending-migrations: ci--meta-debug
./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"""
__version__ = "2021.12.3"
__version__ = "2021.12.5"
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}),
)
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
task.delete()
return Response(status=500)

View File

@ -1,7 +1,7 @@
"""API Authentication"""
from base64 import b64decode
from binascii import Error
from typing import Any, Optional, Union
from typing import Any, Optional
from django.conf import settings
from rest_framework.authentication import BaseAuthentication, get_authorization_header
@ -69,7 +69,7 @@ def token_secret_key(value: str) -> Optional[User]:
class TokenAuthentication(BaseAuthentication):
"""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"""
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.hibp.api import HaveIBeenPwendPolicyViewSet
from authentik.policies.password.api import PasswordPolicyViewSet
from authentik.policies.reputation.api import (
IPReputationViewSet,
ReputationPolicyViewSet,
UserReputationViewSet,
)
from authentik.policies.reputation.api import ReputationPolicyViewSet, ReputationViewSet
from authentik.providers.ldap.api import LDAPOutpostConfigViewSet, LDAPProviderViewSet
from authentik.providers.oauth2.api.provider import OAuth2ProviderViewSet
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/password_expiry", PasswordExpiryPolicyViewSet)
router.register("policies/password", PasswordPolicyViewSet)
router.register("policies/reputation/users", UserReputationViewSet)
router.register("policies/reputation/ips", IPReputationViewSet)
router.register("policies/reputation/scores", ReputationViewSet)
router.register("policies/reputation", ReputationPolicyViewSet)
router.register("providers/all", ProviderViewSet)

View File

@ -3,6 +3,7 @@ from datetime import timedelta
from json import loads
from typing import Optional
from django.contrib.auth import update_session_auth_hash
from django.db.models.query import QuerySet
from django.db.transaction import atomic
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.models import (
USER_ATTRIBUTE_CHANGE_EMAIL,
USER_ATTRIBUTE_CHANGE_NAME,
USER_ATTRIBUTE_CHANGE_USERNAME,
USER_ATTRIBUTE_SA,
USER_ATTRIBUTE_TOKEN_EXPIRING,
@ -134,6 +136,16 @@ class UserSelfSerializer(ModelSerializer):
raise ValidationError("Not allowed to change 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):
"""Check if the user is allowed to change their username"""
if self.instance.group_attributes().get(
@ -144,6 +156,13 @@ class UserSelfSerializer(ModelSerializer):
raise ValidationError("Not allowed to change 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:
model = User
@ -359,6 +378,35 @@ class UserViewSet(UsedByMixin, ModelViewSet):
).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)})
@action(
methods=["PUT"],

View File

@ -15,7 +15,6 @@ import authentik.lib.models
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.core.cache import cache

View File

@ -12,7 +12,6 @@ import authentik.core.models
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.core.cache import cache

View File

@ -1,12 +1,13 @@
"""authentik core models"""
from datetime import timedelta
from hashlib import md5, sha256
from typing import Any, Optional, Type
from typing import Any, Optional
from urllib.parse import urlencode
from uuid import uuid4
from deepmerge import always_merger
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 UserManager as DjangoUserManager
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_TOKEN_EXPIRING = "goauthentik.io/user/token-expires" # nosec
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_CAN_OVERRIDE_IP = "goauthentik.io/user/override-ips"
@ -160,6 +162,22 @@ class User(GuardianUserMixin, AbstractUser):
self.password_change_date = now()
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
def uid(self) -> str:
"""Generate a globall unique UID, based on the user ID and the hashed secret key"""
@ -224,7 +242,7 @@ class Provider(SerializerModel):
raise NotImplementedError
@property
def serializer(self) -> Type[Serializer]:
def serializer(self) -> type[Serializer]:
"""Get serializer for this model"""
raise NotImplementedError
@ -505,7 +523,7 @@ class PropertyMapping(SerializerModel, ManagedModel):
raise NotImplementedError
@property
def serializer(self) -> Type[Serializer]:
def serializer(self) -> type[Serializer]:
"""Get serializer for this model"""
raise NotImplementedError

View File

@ -1,5 +1,5 @@
"""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.sessions.backends.cache import KEY_PREFIX
@ -62,7 +62,7 @@ def user_logged_out_session(sender, request: HttpRequest, user: "User", **_):
@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"""
from authentik.core.models import AuthenticatedSession

View File

@ -1,6 +1,6 @@
"""Source decision helper"""
from enum import Enum
from typing import Any, Optional, Type
from typing import Any, Optional
from django.contrib import messages
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.sources.stage import PLAN_CONTEXT_SOURCES_CONNECTION, PostUserEnrollmentStage
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.planner import (
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.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.stages.password import BACKEND_INBUILT
from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
@ -50,7 +53,10 @@ class SourceFlowManager:
identifier: str
connection_type: Type[UserSourceConnection] = UserSourceConnection
connection_type: type[UserSourceConnection] = UserSourceConnection
enroll_info: dict[str, Any]
policy_context: dict[str, Any]
def __init__(
self,
@ -64,6 +70,7 @@ class SourceFlowManager:
self.identifier = identifier
self.enroll_info = enroll_info
self._logger = get_logger().bind(source=source, identifier=identifier)
self.policy_context = {}
# pylint: disable=too-many-return-statements
def get_action(self, **kwargs) -> tuple[Action, Optional[UserSourceConnection]]:
@ -144,20 +151,23 @@ class SourceFlowManager:
except IntegrityError as exc:
self._logger.warning("failed to get action", exc=exc)
return redirect("/")
self._logger.debug("get_action() says", action=action, connection=connection)
if connection:
if action == Action.LINK:
self._logger.debug("Linking existing user")
return self.handle_existing_user_link(connection)
if action == Action.AUTH:
self._logger.debug("Handling auth user")
return self.handle_auth_user(connection)
if action == Action.ENROLL:
self._logger.debug("Handling enrollment of new user")
return self.handle_enroll(connection)
self._logger.debug("get_action", action=action, connection=connection)
try:
if connection:
if action == Action.LINK:
self._logger.debug("Linking existing user")
return self.handle_existing_user_link(connection)
if action == Action.AUTH:
self._logger.debug("Handling auth user")
return self.handle_auth_user(connection)
if action == Action.ENROLL:
self._logger.debug("Handling enrollment of new user")
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
messages.error(
self.request,
error = (
_(
(
"Request to authenticate with %(source)s has been denied. Please authenticate "
@ -166,7 +176,17 @@ class SourceFlowManager:
% {"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
def get_stages_to_append(self, flow: Flow) -> list[Stage]:
@ -179,7 +199,9 @@ class SourceFlowManager:
]
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"""
# Ensure redirect is carried through when user was trying to
# authorize application
@ -193,8 +215,10 @@ class SourceFlowManager:
PLAN_CONTEXT_SSO: True,
PLAN_CONTEXT_SOURCE: self.source,
PLAN_CONTEXT_REDIRECT: final_redirect,
PLAN_CONTEXT_SOURCES_CONNECTION: connection,
}
)
kwargs.update(self.policy_context)
if not flow:
return HttpResponseBadRequest()
# 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}),
)
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(
self,
@ -264,8 +288,8 @@ class SourceFlowManager:
return HttpResponseBadRequest()
return self._handle_login_flow(
self.source.enrollment_flow,
connection,
**{
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 botocore.exceptions import BotoCoreError, ClientError
from dbbackup.db.exceptions import CommandConnectorError
from django.conf import settings
from django.contrib.humanize.templatetags.humanize import naturaltime
from django.contrib.sessions.backends.cache import KEY_PREFIX
from django.core import management
@ -63,8 +62,6 @@ def should_backup() -> bool:
return False
if not CONFIG.y_bool("postgresql.backup.enabled"):
return False
if settings.DEBUG:
return False
return True

View File

@ -5,6 +5,8 @@
{% block head %}
<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 %}
{% block body %}

View File

@ -5,6 +5,8 @@
{% block head %}
<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 %}
{% block body %}

View File

@ -1,6 +1,5 @@
"""Test Applications API"""
from django.urls import reverse
from django.utils.encoding import force_str
from rest_framework.test import APITestCase
from authentik.core.models import Application
@ -32,7 +31,7 @@ class TestApplicationsAPI(APITestCase):
)
)
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(
reverse(
"authentik_api:application-check-access",
@ -40,14 +39,14 @@ class TestApplicationsAPI(APITestCase):
)
)
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):
"""Test list operation without superuser_full_list"""
self.client.force_login(self.user)
response = self.client.get(reverse("authentik_api:application-list"))
self.assertJSONEqual(
force_str(response.content),
response.content.decode(),
{
"pagination": {
"next": 0,
@ -83,7 +82,7 @@ class TestApplicationsAPI(APITestCase):
reverse("authentik_api:application-list") + "?superuser_full_list=true"
)
self.assertJSONEqual(
force_str(response.content),
response.content.decode(),
{
"pagination": {
"next": 0,

View File

@ -2,7 +2,6 @@
from json import loads
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
@ -28,5 +27,5 @@ class TestAuthenticatedSessionsAPI(APITestCase):
self.client.force_login(self.other_user)
response = self.client.get(reverse("authentik_api:authenticatedsession-list"))
self.assertEqual(response.status_code, 200)
body = loads(force_str(response.content))
body = loads(response.content.decode())
self.assertEqual(body["pagination"]["count"], 1)

View File

@ -1,6 +1,6 @@
"""authentik core models tests"""
from time import sleep
from typing import Callable, Type
from typing import Callable
from django.test import RequestFactory, TestCase
from django.utils.timezone import now
@ -27,7 +27,7 @@ class TestModels(TestCase):
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"""
factory = RequestFactory()
@ -47,7 +47,7 @@ def source_tester_factory(test_model: Type[Stage]) -> Callable:
return tester
def provider_tester_factory(test_model: Type[Stage]) -> Callable:
def provider_tester_factory(test_model: type[Stage]) -> Callable:
"""Test provider"""
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.sources.flow_manager import Action
from authentik.flows.models import Flow, FlowDesignation
from authentik.lib.generators import generate_id
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.views.callback import OAuthSourceFlowManager
@ -17,7 +21,7 @@ class TestSourceFlowManager(TestCase):
def setUp(self) -> None:
super().setUp()
self.source = OAuthSource.objects.create(name="test")
self.source: OAuthSource = OAuthSource.objects.create(name="test")
self.factory = RequestFactory()
self.identifier = generate_id()
@ -143,3 +147,34 @@ class TestSourceFlowManager(TestCase):
action, _ = flow_manager.get_action()
self.assertEqual(action, Action.ENROLL)
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 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.flows.models import FlowDesignation
from authentik.lib.generators import generate_key
from authentik.stages.email.models import EmailStage
from authentik.tenants.models import Tenant
@ -18,11 +24,28 @@ class TestUsersAPI(APITestCase):
def test_update_self(self):
"""Test update_self"""
self.admin.attributes["foo"] = "bar"
self.admin.save()
self.admin.refresh_from_db()
self.client.force_login(self.admin)
response = self.client.put(
reverse("authentik_api:user-update-self"), data={"username": "foo", "name": "foo"}
)
self.admin.refresh_from_db()
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):
"""Test update_self"""
@ -68,6 +91,18 @@ class TestUsersAPI(APITestCase):
)
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):
"""Test user recovery link (no recovery flow set)"""
flow = create_test_flow(FlowDesignation.RECOVERY)

View File

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

View File

@ -1,4 +1,6 @@
"""Crypto API Views"""
from typing import Optional
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.serialization import load_pem_private_key
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_subject = SerializerMethodField()
private_key_available = SerializerMethodField()
private_key_type = SerializerMethodField()
certificate_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"""
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:
"""Get URL to download certificate"""
return (
@ -72,7 +82,7 @@ class CertificateKeyPairSerializer(ModelSerializer):
return value
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.
if value != "":
try:
@ -98,6 +108,7 @@ class CertificateKeyPairSerializer(ModelSerializer):
"cert_expiry",
"cert_subject",
"private_key_available",
"private_key_type",
"certificate_download_url",
"private_key_download_url",
"managed",

View File

@ -44,7 +44,7 @@ class CertificateBuilder:
"""Build self-signed certificate"""
one_day = datetime.timedelta(1, 0, 0)
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()
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.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.serialization import load_pem_private_key
from cryptography.x509 import Certificate, load_pem_x509_certificate
@ -36,8 +41,8 @@ class CertificateKeyPair(ManagedModel, CreatedUpdatedModel):
)
_cert: Optional[Certificate] = None
_private_key: Optional[RSAPrivateKey] = None
_public_key: Optional[RSAPublicKey] = None
_private_key: Optional[RSAPrivateKey | EllipticCurvePrivateKey | Ed25519PrivateKey] = None
_public_key: Optional[RSAPublicKey | EllipticCurvePublicKey | Ed25519PublicKey] = None
@property
def certificate(self) -> Certificate:
@ -49,14 +54,16 @@ class CertificateKeyPair(ManagedModel, CreatedUpdatedModel):
return self._cert
@property
def public_key(self) -> Optional[RSAPublicKey]:
def public_key(self) -> Optional[RSAPublicKey | EllipticCurvePublicKey | Ed25519PublicKey]:
"""Get public key of the private key"""
if not self._public_key:
self._public_key = self.private_key.public_key()
return self._public_key
@property
def private_key(self) -> Optional[RSAPrivateKey]:
def private_key(
self,
) -> Optional[RSAPrivateKey | EllipticCurvePrivateKey | Ed25519PrivateKey]:
"""Get python cryptography PrivateKey instance"""
if not self._private_key and self.key_data != "":
try:

View File

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

View File

@ -146,7 +146,7 @@ class TestCrypto(APITestCase):
client_secret=generate_key(),
authorization_flow=create_test_flow(),
redirect_uris="http://localhost",
rsa_key=keypair,
signing_key=keypair,
)
response = self.client.get(
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.utils import PassiveSerializer
from authentik.events.models import (
Event,
Notification,
NotificationSeverity,
NotificationTransport,
NotificationTransportError,
TransportMode,
)
from authentik.events.utils import get_user
class NotificationTransportSerializer(ModelSerializer):
@ -86,6 +88,12 @@ class NotificationTransportViewSet(UsedByMixin, ModelViewSet):
severity=NotificationSeverity.NOTICE,
body=f"Test Notification from transport {transport.name}",
user=request.user,
event=Event(
action="Test",
user=get_user(request.user),
app=self.__class__.__module__,
context={"foo": "bar"},
),
)
try:
response = NotificationTransportTestSerializer(

View File

@ -35,12 +35,11 @@ class GeoIPReader:
def __open(self):
"""Get GeoIP Reader, if configured, otherwise none"""
path = CONFIG.y("authentik.geoip")
path = CONFIG.y("geoip")
if path == "" or not path:
return
try:
reader = Reader(path)
self.__reader = reader
self.__reader = Reader(path)
self.__last_mtime = stat(path).st_mtime
LOGGER.info("Loaded GeoIP database", last_write=self.__last_mtime)
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")
db_alias = schema_editor.connection.alias
for event in Event.objects.all():
for event in Event.objects.using(db_alias).all():
event.delete()
# Because event objects cannot be updated, we have to re-create them
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")
db_alias = schema_editor.connection.alias
for event in Event.objects.all():
for event in Event.objects.using(db_alias).all():
event.delete()
# Because event objects cannot be updated, we have to re-create them
event.pk = None

View File

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

View File

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

View File

@ -1,11 +1,14 @@
"""flow exceptions"""
from authentik.lib.sentry import SentryIgnoredException
from authentik.policies.types import PolicyResult
class FlowNonApplicableException(SentryIgnoredException):
"""Flow does not apply to current user (denied by policy)."""
policy_result: PolicyResult
class EmptyFlowException(SentryIgnoredException):
"""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-source-enrollment": "Welcome to authentik! Please select a username.",
"default-source-authentication": "Welcome to authentik!",
"default-provider-authorization-implicit-consent": "Default Provider Authorization Flow (implicit consent)",
"default-provider-authorization-explicit-consent": "Default Provider Authorization Flow (explicit consent)",
"default-provider-authorization-implicit-consent": "Redirecting to %(app)s",
"default-provider-authorization-explicit-consent": "Redirecting to %(app)s",
"default-password-change": "Change password",
}
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"""
from base64 import b64decode, b64encode
from pickle import dumps, loads # nosec
from typing import TYPE_CHECKING, Optional, Type
from typing import TYPE_CHECKING, Optional
from uuid import uuid4
from django.db import models
@ -63,7 +63,7 @@ class Stage(SerializerModel):
objects = InheritanceManager()
@property
def type(self) -> Type["StageView"]:
def type(self) -> type["StageView"]:
"""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
if hasattr(self, "__in_memory_type"):
@ -86,7 +86,7 @@ class Stage(SerializerModel):
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."""
stage = Stage()
# Because we can't pickle a locally generated function,

View File

@ -152,7 +152,9 @@ class FlowPlanner:
engine.build()
result = engine.result
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
cached_plan_key = cache_key(self.flow, user)
cached_plan = cache.get(cached_plan_key, None)

View File

@ -1,4 +1,6 @@
"""authentik stage Base view"""
from typing import TYPE_CHECKING, Optional
from django.contrib.auth.models import AnonymousUser
from django.http import HttpRequest
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.flows.challenge import (
AccessDeniedChallenge,
Challenge,
ChallengeResponse,
ChallengeTypes,
ContextualFlowInfo,
HttpChallengeResponse,
WithUserInfoChallenge,
)
from authentik.flows.models import InvalidResponseAction
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"
LOGGER = get_logger()
@ -28,11 +34,11 @@ LOGGER = get_logger()
class StageView(View):
"""Abstract Stage, inherits TemplateView but can be combined with FormView"""
executor: FlowExecutorView
executor: "FlowExecutorView"
request: HttpRequest = None
def __init__(self, executor: FlowExecutorView, **kwargs):
def __init__(self, executor: "FlowExecutorView", **kwargs):
self.executor = executor
super().__init__(**kwargs)
@ -43,6 +49,8 @@ class StageView(View):
other things besides the form display.
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:
return User(
username=self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER_IDENTIFIER),
@ -108,6 +116,8 @@ class ChallengeStageView(StageView):
def format_title(self) -> str:
"""Allow usage of placeholder in flow title."""
if not self.executor.plan:
return self.executor.flow.title
return self.executor.flow.title % {
"app": self.executor.plan.context.get(PLAN_CONTEXT_APPLICATION, "")
}
@ -169,3 +179,27 @@ class ChallengeStageView(StageView):
stage_view=self,
)
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.test.client import RequestFactory
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.flows.challenge import ChallengeTypes
from authentik.flows.exceptions import FlowNonApplicableException
from authentik.flows.markers import ReevaluateMarker, StageMarker
from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding, InvalidResponseAction
from authentik.flows.planner import FlowPlan, FlowPlanner
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.lib.config import CONFIG
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)
class TestFlowExecutor(APITestCase):
class TestFlowExecutor(FlowTestCase):
"""Test executor"""
def setUp(self):
@ -90,18 +88,11 @@ class TestFlowExecutor(APITestCase):
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
)
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
force_str(response.content),
{
"component": "ak-stage-access-denied",
"error_message": FlowNonApplicableException.__doc__,
"flow_info": {
"background": flow.background_url,
"cancel_url": reverse("authentik_flows:cancel"),
"title": "",
},
"type": ChallengeTypes.NATIVE.value,
},
self.assertStageResponse(
response,
flow=flow,
error_message=FlowNonApplicableException.__doc__,
component="ak-stage-access-denied",
)
@patch(
@ -283,14 +274,7 @@ class TestFlowExecutor(APITestCase):
# We do this request without the patch, so the policy results in false
response = self.client.post(exec_url)
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
force_str(response.content),
{
"component": "xak-flow-redirect",
"to": reverse("authentik_core:root-redirect"),
"type": ChallengeTypes.REDIRECT.value,
},
)
self.assertStageRedirects(response, reverse("authentik_core:root-redirect"))
def test_reevaluate_keep(self):
"""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
response = self.client.post(exec_url)
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
force_str(response.content),
{
"component": "xak-flow-redirect",
"to": reverse("authentik_core:root-redirect"),
"type": ChallengeTypes.REDIRECT.value,
},
)
self.assertStageRedirects(response, reverse("authentik_core:root-redirect"))
def test_reevaluate_remove_consecutive(self):
"""Test planner with re-evaluate (consecutive stages are removed)"""
@ -407,18 +384,7 @@ class TestFlowExecutor(APITestCase):
# First request, run the planner
response = self.client.get(exec_url)
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
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": "",
},
},
)
self.assertStageResponse(response, flow, component="ak-stage-dummy")
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
response = self.client.get(exec_url)
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
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": "",
},
},
)
self.assertStageResponse(response, flow, component="ak-stage-dummy")
# fourth request, this confirms the last stage (dummy4)
# We do this request without the patch, so the policy results in false
response = self.client.post(exec_url)
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
force_str(response.content),
{
"component": "xak-flow-redirect",
"to": reverse("authentik_core:root-redirect"),
"type": ChallengeTypes.REDIRECT.value,
},
)
self.assertStageRedirects(response, reverse("authentik_core:root-redirect"))
def test_stageview_user_identifier(self):
"""Test PLAN_CONTEXT_PENDING_USER_IDENTIFIER"""
@ -532,35 +480,16 @@ class TestFlowExecutor(APITestCase):
# First request, run the planner
response = self.client.get(exec_url)
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
force_str(response.content),
{
"type": ChallengeTypes.NATIVE.value,
"component": "ak-stage-identification",
"flow_info": {
"background": flow.background_url,
"cancel_url": reverse("authentik_flows:cancel"),
"title": "",
},
"password_fields": False,
"primary_action": "Log in",
"sources": [],
"show_source_labels": False,
"user_fields": [UserFields.E_MAIL],
},
self.assertStageResponse(
response,
flow,
component="ak-stage-identification",
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)
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
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,
},
)
self.assertStageResponse(response, flow, component="ak-stage-access-denied")

View File

@ -1,5 +1,5 @@
"""base model tests"""
from typing import Callable, Type
from typing import Callable
from django.test import TestCase
@ -12,7 +12,7 @@ class TestModels(TestCase):
"""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"""
def tester(self: TestModels):

View File

@ -1,5 +1,5 @@
"""stage view tests"""
from typing import Callable, Type
from typing import Callable
from django.test import RequestFactory, TestCase
@ -16,7 +16,7 @@ class TestViews(TestCase):
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"""
def tester(self: TestViews):

View File

@ -2,7 +2,7 @@
from contextlib import contextmanager
from copy import deepcopy
from json import loads
from typing import Any, Type
from typing import Any
from dacite import from_dict
from dacite.exceptions import DaciteError
@ -87,7 +87,7 @@ class FlowImporter:
def _validate_single(self, entry: FlowBundleEntry) -> BaseSerializer:
"""Validate a single entry"""
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):
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.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse
from django.urls.base import reverse
from django.utils.decorators import method_decorator
from django.views.decorators.clickjacking import xframe_options_sameorigin
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.events.models import Event, EventAction, cleanse_dict
from authentik.flows.challenge import (
AccessDeniedChallenge,
Challenge,
ChallengeResponse,
ChallengeTypes,
@ -51,6 +49,7 @@ from authentik.flows.planner import (
FlowPlan,
FlowPlanner,
)
from authentik.flows.stage import AccessDeniedChallengeView
from authentik.lib.sentry import SentryIgnoredException
from authentik.lib.utils.errors import exception_to_string
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"
)
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))
def stage_ok(self) -> HttpResponse:
@ -412,21 +405,9 @@ class FlowExecutorView(APIView):
is a superuser."""
self._logger.debug("f(exec): Stage invalid")
self.cancel()
response = HttpChallengeResponse(
AccessDeniedChallenge(
{
"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)
challenge_view = AccessDeniedChallengeView(self, error_message)
challenge_view.request = self.request
return to_stage_response(self.request, challenge_view.get(self.request))
def cancel(self):
"""Cancel current execution and return a redirect"""

View File

@ -78,6 +78,7 @@ footer_links:
- name: authentik Website
href: https://goauthentik.io/?utm_source=authentik
default_user_change_name: true
default_user_change_email: 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:
_, exc_value, _ = hint["exc_info"]
if isinstance(exc_value, ignored_classes):
LOGGER.debug("dropping exception", exception=exc_value)
LOGGER.debug("dropping exception", exc=exc_value)
return None
if "logger" in event:
if event["logger"] in [
@ -114,6 +114,6 @@ def before_send(event: dict, hint: dict) -> Optional[dict]:
]:
return 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 event

View File

@ -13,4 +13,4 @@ class TestSentry(TestCase):
def test_error_sent(self):
"""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"""
from typing import Callable, Type
from typing import Callable
from django.test import TestCase
from rest_framework.serializers import BaseSerializer
@ -13,7 +13,7 @@ class TestModels(TestCase):
"""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"""
def tester(self: TestModels):

View File

@ -2,7 +2,6 @@
import os
from importlib import import_module
from pathlib import Path
from typing import Union
from django.conf import settings
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__}"
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"""
if not path:
return None

View File

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

View File

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

View File

@ -1,4 +1,6 @@
"""Outpost API Views"""
from os import environ
from dacite.core import from_dict
from dacite.exceptions import DaciteError
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.viewsets import ModelViewSet
from authentik import ENV_GIT_HASH_KEY
from authentik.core.api.providers import ProviderSerializer
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import PassiveSerializer, is_dict
@ -98,8 +101,12 @@ class OutpostHealthSerializer(PassiveSerializer):
last_seen = DateTimeField(read_only=True)
version = CharField(read_only=True)
version_should = CharField(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):
"""Filter for Outposts"""
@ -146,6 +153,8 @@ class OutpostViewSet(UsedByMixin, ModelViewSet):
"version": state.version,
"version_should": state.version_should,
"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)

View File

@ -9,7 +9,11 @@ from structlog.testing import capture_logs
from authentik import ENV_GIT_HASH_KEY, __version__
from authentik.lib.config import CONFIG
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"
@ -28,11 +32,25 @@ class DeploymentPort:
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:
"""Base Outpost deployment controller"""
deployment_ports: list[DeploymentPort]
client: BaseClient
outpost: Outpost
connection: OutpostServiceConnection
@ -63,6 +81,14 @@ class BaseController:
self.down()
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:
"""Return a static deployment configuration"""
raise NotImplementedError

View File

@ -1,17 +1,75 @@
"""Docker controller"""
from time import sleep
from typing import Optional
from urllib.parse import urlparse
from django.conf import settings
from django.utils.text import slugify
from docker import DockerClient
from docker import DockerClient as UpstreamDockerClient
from docker.errors import DockerException, NotFound
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 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.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):
@ -27,8 +85,9 @@ class DockerController(BaseController):
if outpost.managed == MANAGED_OUTPOST:
return
try:
self.client = connection.client()
except ServiceConnectionInvalid as exc:
self.client = DockerClient(connection)
except DockerException as exc:
self.logger.warning(exc)
raise ControllerException from exc
@property

View File

@ -1,34 +1,67 @@
"""Kubernetes deployment controller"""
from io import StringIO
from typing import Type
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 structlog.testing import capture_logs
from urllib3.exceptions import HTTPError
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.deployment import DeploymentReconciler
from authentik.outposts.controllers.k8s.secret import SecretReconciler
from authentik.outposts.controllers.k8s.service import ServiceReconciler
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):
"""Manage deployment of outpost in kubernetes"""
reconcilers: dict[str, Type[KubernetesObjectReconciler]]
reconcilers: dict[str, type[KubernetesObjectReconciler]]
reconcile_order: list[str]
client: ApiClient
client: KubernetesClient
connection: KubernetesServiceConnection
def __init__(self, outpost: Outpost, connection: KubernetesServiceConnection) -> None:
super().__init__(outpost, connection)
self.client = connection.client()
self.client = KubernetesClient(connection)
self.reconcilers = {
"secret": SecretReconciler,
"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"""
from os import unlink
from pathlib import Path
from tempfile import gettempdir
from typing import Optional
@ -14,6 +15,8 @@ class DockerInlineTLS:
verification_kp: Optional[CertificateKeyPair]
authentication_kp: Optional[CertificateKeyPair]
_paths: list[str]
def __init__(
self,
verification_kp: Optional[CertificateKeyPair],
@ -21,14 +24,21 @@ class DockerInlineTLS:
) -> None:
self.verification_kp = verification_kp
self.authentication_kp = authentication_kp
self._paths = []
def write_file(self, name: str, contents: str) -> str:
"""Wrapper for mkstemp that uses fdopen"""
path = Path(gettempdir(), name)
with open(path, "w", encoding="utf8") as _file:
_file.write(contents)
self._paths.append(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:
"""Create TLSConfig with Certificate Key pairs"""
# 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 datetime import datetime
from os import environ
from typing import Iterable, Optional, Union
from typing import Iterable, Optional
from uuid import uuid4
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.models.base import Model
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.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 packaging.version import LegacyVersion, Version, parse
from structlog.stdlib import get_logger
from urllib3.exceptions import HTTPError
from authentik import ENV_GIT_HASH_KEY, __version__
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.managed.models import ManagedModel
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__)
OUTPOST_HELLO_INTERVAL = 10
@ -86,7 +76,7 @@ class OutpostConfig:
class OutpostModel(Model):
"""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 [self]
@ -149,10 +139,6 @@ class OutpostServiceConnection(models.Model):
return OutpostServiceConnectionState("", False)
return state
def fetch_state(self) -> OutpostServiceConnectionState:
"""Fetch current Service Connection state"""
raise NotImplementedError
@property
def component(self) -> str:
"""Return component used to edit this object"""
@ -210,35 +196,6 @@ class DockerServiceConnection(OutpostServiceConnection):
def __str__(self) -> str:
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:
verbose_name = _("Docker Service-Connection")
@ -265,27 +222,6 @@ class KubernetesServiceConnection(OutpostServiceConnection):
def __str__(self) -> str:
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:
verbose_name = _("Kubernetes Service-Connection")
@ -385,7 +321,8 @@ class Outpost(ManagedModel):
user.user_permissions.add(permission.first())
LOGGER.debug(
"Updated service account's permissions",
perms=UserObjectPermission.objects.filter(user=user),
obj_perms=UserObjectPermission.objects.filter(user=user),
perms=user.user_permissions.all(),
)
@property
@ -438,9 +375,9 @@ class Outpost(ManagedModel):
Token.objects.filter(identifier=self.token_identifier).delete()
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"""
objects: list[Union[models.Model, str]] = [
objects: list[models.Model | str] = [
self,
"authentik_events.add_event",
]
@ -449,6 +386,10 @@ class Outpost(ManagedModel):
objects.extend(provider.get_required_objects())
else:
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
def __str__(self) -> str:
@ -463,7 +404,7 @@ class OutpostState:
channel_ids: list[str] = field(default_factory=list)
last_seen: Optional[datetime] = 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="")
_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.outposts.models import Outpost, OutpostServiceConnection
from authentik.outposts.tasks import CACHE_KEY_OUTPOST_DOWN, outpost_controller, outpost_post_save
from authentik.tenants.models import Tenant
LOGGER = get_logger()
UPDATE_TRIGGERING_MODELS = (
@ -17,6 +18,7 @@ UPDATE_TRIGGERING_MODELS = (
OutpostServiceConnection,
Provider,
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.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 (
DockerServiceConnection,
KubernetesServiceConnection,
@ -45,21 +47,21 @@ LOGGER = get_logger()
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"""
if not outpost.service_connection:
return None
service_connection = outpost.service_connection
if outpost.type == OutpostType.PROXY:
if isinstance(service_connection, DockerServiceConnection):
return ProxyDockerController(outpost, service_connection)
return ProxyDockerController
if isinstance(service_connection, KubernetesServiceConnection):
return ProxyKubernetesController(outpost, service_connection)
return ProxyKubernetesController
if outpost.type == OutpostType.LDAP:
if isinstance(service_connection, DockerServiceConnection):
return LDAPDockerController(outpost, service_connection)
return LDAPDockerController
if isinstance(service_connection, KubernetesServiceConnection):
return LDAPKubernetesController(outpost, service_connection)
return LDAPKubernetesController
return None
@ -71,7 +73,12 @@ def outpost_service_connection_state(connection_pk: Any):
)
if not connection:
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)
@ -114,14 +121,15 @@ def outpost_controller(
return
self.set_uid(slugify(outpost.name))
try:
controller = controller_for_outpost(outpost)
if not controller:
controller_type = controller_for_outpost(outpost)
if not controller_type:
return
logs = getattr(controller, f"{action}_with_logs")()
LOGGER.debug("---------------Outpost Controller logs starting----------------")
for log in logs:
LOGGER.debug(log)
LOGGER.debug("-----------------Outpost Controller logs end-------------------")
with controller_type(outpost, outpost.service_connection) as controller:
logs = getattr(controller, f"{action}_with_logs")()
LOGGER.debug("---------------Outpost Controller logs starting----------------")
for log in logs:
LOGGER.debug(log)
LOGGER.debug("-----------------Outpost Controller logs end-------------------")
except (ControllerException, ServiceConnectionInvalid) as exc:
self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc))
else:

View File

@ -1,16 +1,14 @@
"""Password flow tests"""
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.flows.challenge import ChallengeTypes
from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding
from authentik.flows.tests import FlowTestCase
from authentik.policies.password.models import PasswordPolicy
from authentik.stages.prompt.models import FieldTypes, Prompt, PromptStage
class TestPasswordPolicyFlow(APITestCase):
class TestPasswordPolicyFlow(FlowTestCase):
"""Test Password Policy"""
def setUp(self) -> None:
@ -53,29 +51,22 @@ class TestPasswordPolicyFlow(APITestCase):
{"password": "akadmin"},
)
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
force_str(response.content),
{
"component": "ak-stage-prompt",
"fields": [
{
"field_key": "password",
"label": "PASSWORD_LABEL",
"order": 0,
"placeholder": "PASSWORD_PLACEHOLDER",
"required": True,
"type": "password",
"sub_text": "",
}
],
"flow_info": {
"background": self.flow.background_url,
"cancel_url": reverse("authentik_flows:cancel"),
"title": "",
},
"response_errors": {
"non_field_errors": [{"code": "invalid", "string": self.policy.error_message}]
},
"type": ChallengeTypes.NATIVE.value,
self.assertStageResponse(
response,
self.flow,
component="ak-stage-prompt",
fields=[
{
"field_key": "password",
"label": "PASSWORD_LABEL",
"order": 0,
"placeholder": "PASSWORD_PLACEHOLDER",
"required": True,
"type": "password",
"sub_text": "",
}
],
response_errors={
"non_field_errors": [{"code": "invalid", "string": self.policy.error_message}]
},
)

View File

@ -1,11 +1,11 @@
"""Source API Views"""
"""Reputation policy API Views"""
from rest_framework import mixins
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import GenericViewSet, ModelViewSet
from authentik.core.api.used_by import UsedByMixin
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):
@ -29,59 +29,32 @@ class ReputationPolicyViewSet(UsedByMixin, ModelViewSet):
ordering = ["name"]
class IPReputationSerializer(ModelSerializer):
"""IPReputation Serializer"""
class ReputationSerializer(ModelSerializer):
"""Reputation Serializer"""
class Meta:
model = IPReputation
model = Reputation
fields = [
"pk",
"identifier",
"ip",
"ip_geo_data",
"score",
"updated",
]
class IPReputationViewSet(
class ReputationViewSet(
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
UsedByMixin,
mixins.ListModelMixin,
GenericViewSet,
):
"""IPReputation Viewset"""
"""Reputation Viewset"""
queryset = IPReputation.objects.all()
serializer_class = IPReputationSerializer
search_fields = ["ip", "score"]
filterset_fields = ["ip", "score"]
queryset = Reputation.objects.all()
serializer_class = ReputationSerializer
search_fields = ["identifier", "ip", "score"]
filterset_fields = ["identifier", "ip", "score"]
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):
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"""
from django.core.cache import cache
from uuid import uuid4
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 rest_framework.serializers import BaseSerializer
from structlog import get_logger
from authentik.lib.models import SerializerModel
from authentik.lib.utils.http import get_client_ip
from authentik.policies.models import Policy
from authentik.policies.types import PolicyRequest, PolicyResult
LOGGER = get_logger()
CACHE_KEY_IP_PREFIX = "authentik_reputation_ip_"
CACHE_KEY_USER_PREFIX = "authentik_reputation_user_"
CACHE_KEY_PREFIX = "goauthentik.io/policies/reputation/scores/"
class ReputationPolicy(Policy):
@ -33,20 +36,22 @@ class ReputationPolicy(Policy):
def passes(self, request: PolicyRequest) -> PolicyResult:
remote_ip = get_client_ip(request.http_request)
passing = False
query = Q()
if self.check_ip:
score = cache.get_or_set(CACHE_KEY_IP_PREFIX + remote_ip, 0)
passing += passing or score <= self.threshold
LOGGER.debug("Score for IP", ip=remote_ip, score=score, passing=passing)
query |= Q(ip=remote_ip)
if self.check_username:
score = cache.get_or_set(CACHE_KEY_USER_PREFIX + request.user.username, 0)
passing += passing or score <= self.threshold
LOGGER.debug(
"Score for Username",
username=request.user.username,
score=score,
passing=passing,
)
query |= Q(identifier=request.user.username)
score = (
Reputation.objects.filter(query).aggregate(total_score=Sum("score"))["total_score"] or 0
)
passing = score <= self.threshold
LOGGER.debug(
"Score for user",
username=request.user.username,
remote_ip=remote_ip,
score=score,
passing=passing,
)
return PolicyResult(bool(passing))
class Meta:
@ -55,23 +60,27 @@ class ReputationPolicy(Policy):
verbose_name_plural = _("Reputation Policies")
class IPReputation(models.Model):
"""Store score coming from the same IP"""
class Reputation(SerializerModel):
"""Reputation for user and or IP."""
ip = models.GenericIPAddressField(unique=True)
score = models.IntegerField(default=0)
updated = models.DateTimeField(auto_now=True)
reputation_uuid = models.UUIDField(primary_key=True, unique=True, default=uuid4)
def __str__(self):
return f"IPReputation for {self.ip} @ {self.score}"
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)
class UserReputation(models.Model):
"""Store score attempting to log in as the same username"""
@property
def serializer(self) -> BaseSerializer:
from authentik.policies.reputation.api import ReputationSerializer
username = models.TextField()
score = models.IntegerField(default=0)
updated = models.DateTimeField(auto_now=True)
return ReputationSerializer
def __str__(self):
return f"UserReputation for {self.username} @ {self.score}"
def __str__(self) -> str:
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
CELERY_BEAT_SCHEDULE = {
"policies_reputation_ip_save": {
"task": "authentik.policies.reputation.tasks.save_ip_reputation",
"schedule": crontab(minute="*/5"),
"options": {"queue": "authentik_scheduled"},
},
"policies_reputation_user_save": {
"task": "authentik.policies.reputation.tasks.save_user_reputation",
"policies_reputation_save": {
"task": "authentik.policies.reputation.tasks.save_reputation",
"schedule": crontab(minute="*/5"),
"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.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
LOGGER = get_logger()
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"""
remote_ip = get_client_ip(request)
try:
# 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)
cache.incr(CACHE_KEY_IP_PREFIX + remote_ip, amount)
cache.get_or_set(CACHE_KEY_USER_PREFIX + username, 0, CACHE_TIMEOUT)
cache.incr(CACHE_KEY_USER_PREFIX + username, amount)
score = cache.get_or_set(
CACHE_KEY_PREFIX + remote_ip + identifier,
{"ip": remote_ip, "identifier": identifier, "score": 0},
CACHE_TIMEOUT,
)
score["score"] += amount
cache.set(CACHE_KEY_PREFIX + remote_ip + identifier, score)
except ValueError as 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)

View File

@ -2,14 +2,15 @@
from django.core.cache import cache
from structlog.stdlib import get_logger
from authentik.events.geo import GEOIP_READER
from authentik.events.monitored_tasks import (
MonitoredTask,
TaskResult,
TaskResultStatus,
prefill_task,
)
from authentik.policies.reputation.models import IPReputation, UserReputation
from authentik.policies.reputation.signals import CACHE_KEY_IP_PREFIX, CACHE_KEY_USER_PREFIX
from authentik.policies.reputation.models import Reputation
from authentik.policies.reputation.signals import CACHE_KEY_PREFIX
from authentik.root.celery import CELERY_APP
LOGGER = get_logger()
@ -17,29 +18,16 @@ LOGGER = get_logger()
@CELERY_APP.task(bind=True, base=MonitoredTask)
@prefill_task
def save_ip_reputation(self: MonitoredTask):
def save_reputation(self: MonitoredTask):
"""Save currently cached reputation to database"""
objects_to_update = []
for key, score in cache.get_many(cache.keys(CACHE_KEY_IP_PREFIX + "*")).items():
remote_ip = key.replace(CACHE_KEY_IP_PREFIX, "")
rep, _ = IPReputation.objects.get_or_create(ip=remote_ip)
rep.score = score
for _, score in cache.get_many(cache.keys(CACHE_KEY_PREFIX + "*")).items():
rep, _ = Reputation.objects.get_or_create(
ip=score["ip"],
identifier=score["identifier"],
)
rep.ip_geo_data = GEOIP_READER.city_dict(score["ip"]) or {}
rep.score = score["score"]
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"])
)
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 authentik.core.models import User
from authentik.lib.utils.http import DEFAULT_IP
from authentik.policies.reputation.models import (
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.reputation.models import CACHE_KEY_PREFIX, Reputation, ReputationPolicy
from authentik.policies.reputation.tasks import save_reputation
from authentik.policies.types import PolicyRequest
@ -24,9 +17,8 @@ class TestReputationPolicy(TestCase):
self.request = self.request_factory.get("/")
self.test_ip = "127.0.0.1"
self.test_username = "test"
cache.delete(CACHE_KEY_IP_PREFIX + self.test_ip)
cache.delete(CACHE_KEY_IP_PREFIX + DEFAULT_IP)
cache.delete(CACHE_KEY_USER_PREFIX + self.test_username)
keys = cache.keys(CACHE_KEY_PREFIX + "*")
cache.delete_many(keys)
# We need a user for the one-to-one in userreputation
self.user = User.objects.create(username=self.test_username)
@ -35,20 +27,26 @@ class TestReputationPolicy(TestCase):
# Trigger negative reputation
authenticate(self.request, username=self.test_username, password=self.test_username)
# 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_ip_reputation.delay().get()
self.assertEqual(IPReputation.objects.get(ip=self.test_ip).score, -1)
save_reputation.delay().get()
self.assertEqual(Reputation.objects.get(ip=self.test_ip).score, -1)
def test_user_reputation(self):
"""test User reputation"""
# Trigger negative reputation
authenticate(self.request, username=self.test_username, password=self.test_username)
# 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_user_reputation.delay().get()
self.assertEqual(UserReputation.objects.get(username=self.test_username).score, -1)
save_reputation.delay().get()
self.assertEqual(Reputation.objects.get(identifier=self.test_username).score, -1)
def test_policy(self):
"""Test Policy"""

View File

@ -23,6 +23,6 @@ def invalidate_policy_cache(sender, instance, **_):
total += len(keys)
cache.delete_many(keys)
LOGGER.debug("Invalidating policy cache", policy=instance, keys=total)
# Also delete user application cache
keys = cache.keys(user_app_cache_key("*")) or []
cache.delete_many(keys)
# Also delete user application cache
keys = cache.keys(user_app_cache_key("*")) or []
cache.delete_many(keys)

View File

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

View File

@ -1,31 +1,23 @@
"""OAuth2Provider API Views"""
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework.decorators import action
from rest_framework.fields import CharField
from rest_framework.generics import get_object_or_404
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ValidationError
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.providers import ProviderSerializer
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import PassiveSerializer
from authentik.core.models import Provider
from authentik.providers.oauth2.models import JWTAlgorithms, OAuth2Provider
from authentik.providers.oauth2.models import OAuth2Provider
class OAuth2ProviderSerializer(ProviderSerializer):
"""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:
model = OAuth2Provider
@ -37,8 +29,7 @@ class OAuth2ProviderSerializer(ProviderSerializer):
"access_code_validity",
"token_validity",
"include_claims_in_id_token",
"jwt_alg",
"rsa_key",
"signing_key",
"redirect_uris",
"sub_mode",
"property_mappings",
@ -73,8 +64,7 @@ class OAuth2ProviderViewSet(UsedByMixin, ModelViewSet):
"access_code_validity",
"token_validity",
"include_claims_in_id_token",
"jwt_alg",
"rsa_key",
"signing_key",
"redirect_uris",
"sub_mode",
"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 datetime import datetime
from hashlib import sha256
from typing import Any, Optional, Type
from typing import Any, Optional
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 django.db import models
from django.http import HttpRequest
@ -88,6 +90,7 @@ class JWTAlgorithms(models.TextChoices):
HS256 = "HS256", _("HS256 (Symmetric Encryption)")
RS256 = "RS256", _("RS256 (Asymmetric Encryption)")
EC256 = "EC256", _("EC256 (Asymmetric Encryption)")
class ScopeMapping(PropertyMapping):
@ -109,7 +112,7 @@ class ScopeMapping(PropertyMapping):
return "ak-property-mapping-scope-form"
@property
def serializer(self) -> Type[Serializer]:
def serializer(self) -> type[Serializer]:
from authentik.providers.oauth2.api.scope import ScopeMappingSerializer
return ScopeMappingSerializer
@ -145,13 +148,6 @@ class OAuth2Provider(Provider):
verbose_name=_("Client Secret"),
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(
default="",
blank=True,
@ -207,7 +203,7 @@ class OAuth2Provider(Provider):
help_text=_(("Configure how the issuer field of the ID Token should be filled.")),
)
rsa_key = models.ForeignKey(
signing_key = models.ForeignKey(
CertificateKeyPair,
verbose_name=_("RSA Key"),
on_delete=models.SET_NULL,
@ -231,29 +227,18 @@ class OAuth2Provider(Provider):
token.access_token = token.create_access_token(user, request)
return token
def get_jwt_key(self) -> str:
"""
Takes a provider and returns the set of keys associated with it.
Returns a list of keys.
"""
if self.jwt_alg == JWTAlgorithms.RS256:
# if the user selected RS256 but didn't select a
# CertificateKeyPair, we fall back to HS256
if not self.rsa_key:
Event.new(
EventAction.CONFIGURATION_ERROR,
provider=self,
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_jwt_key(self) -> tuple[str, str]:
"""Get either the configured certificate or the client secret"""
if not self.signing_key:
# No Certificate at all, assume HS256
return self.client_secret, JWTAlgorithms.HS256
key: CertificateKeyPair = self.signing_key
private_key = key.private_key
if isinstance(private_key, RSAPrivateKey):
return key.key_data, JWTAlgorithms.RS256
if isinstance(private_key, EllipticCurvePrivateKey):
return key.key_data, JWTAlgorithms.EC256
raise Exception(f"Invalid private key type: {type(private_key)}")
def get_issuer(self, request: HttpRequest) -> Optional[str]:
"""Get issuer, based on request"""
@ -282,7 +267,7 @@ class OAuth2Provider(Provider):
return "ak-provider-oauth2-form"
@property
def serializer(self) -> Type[Serializer]:
def serializer(self) -> type[Serializer]:
from authentik.providers.oauth2.api.provider import OAuth2ProviderSerializer
return OAuth2ProviderSerializer
@ -293,13 +278,13 @@ class OAuth2Provider(Provider):
def encode(self, payload: dict[str, Any]) -> str:
"""Represent the ID Token as a JSON Web Token (JWT)."""
headers = {}
if self.rsa_key:
headers["kid"] = self.rsa_key.kid
key = self.get_jwt_key()
if self.signing_key:
headers["kid"] = self.signing_key.kid
key, alg = self.get_jwt_key()
# If the provider does not have an RSA Key assigned, it was switched to Symmetric
self.refresh_from_db()
# pyright: reportGeneralTypeIssues=false
return encode(payload, key, algorithm=self.jwt_alg, headers=headers)
return encode(payload, key, algorithm=alg, headers=headers)
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"""
from django.test import RequestFactory
from django.urls import reverse
from django.utils.encoding import force_str
from authentik.core.models import Application
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()
self.assertJSONEqual(
force_str(response.content),
response.content.decode(),
{
"component": "xak-flow-redirect",
"type": ChallengeTypes.REDIRECT.value,
@ -218,7 +217,7 @@ class TestAuthorize(OAuthTestCase):
client_secret=generate_key(),
authorization_flow=flow,
redirect_uris="http://localhost",
rsa_key=create_test_cert(),
signing_key=create_test_cert(),
)
Application.objects.create(name="app", slug="app", provider=provider)
state = generate_id()
@ -240,7 +239,7 @@ class TestAuthorize(OAuthTestCase):
)
token: RefreshToken = RefreshToken.objects.filter(user=user).first()
self.assertJSONEqual(
force_str(response.content),
response.content.decode(),
{
"component": "xak-flow-redirect",
"type": ChallengeTypes.REDIRECT.value,

View File

@ -3,7 +3,6 @@ import json
from django.test import RequestFactory
from django.urls.base import reverse
from django.utils.encoding import force_str
from authentik.core.models import Application
from authentik.core.tests.utils import create_test_cert, create_test_flow
@ -25,13 +24,13 @@ class TestJWKS(OAuthTestCase):
client_id="test",
authorization_flow=create_test_flow(),
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)
response = self.client.get(
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)
def test_hs256(self):
@ -46,4 +45,4 @@ class TestJWKS(OAuthTestCase):
response = self.client.get(
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.urls import reverse
from django.utils.encoding import force_str
from authentik.core.models import Application
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(),
authorization_flow=create_test_flow(),
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()
user = create_test_admin_user()
@ -62,7 +61,7 @@ class TestToken(OAuthTestCase):
client_secret=generate_key(),
authorization_flow=create_test_flow(),
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()
request = self.factory.post(
@ -85,7 +84,7 @@ class TestToken(OAuthTestCase):
client_secret=generate_key(),
authorization_flow=create_test_flow(),
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()
user = create_test_admin_user()
@ -114,7 +113,7 @@ class TestToken(OAuthTestCase):
client_secret=generate_key(),
authorization_flow=create_test_flow(),
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
self.app.provider = provider
@ -135,7 +134,7 @@ class TestToken(OAuthTestCase):
)
new_token: RefreshToken = RefreshToken.objects.filter(user=user).first()
self.assertJSONEqual(
force_str(response.content),
response.content.decode(),
{
"access_token": new_token.access_token,
"refresh_token": new_token.refresh_token,
@ -156,7 +155,7 @@ class TestToken(OAuthTestCase):
client_secret=generate_key(),
authorization_flow=create_test_flow(),
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
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-Origin"], "http://local.invalid")
self.assertJSONEqual(
force_str(response.content),
response.content.decode(),
{
"access_token": new_token.access_token,
"refresh_token": new_token.refresh_token,
@ -205,7 +204,7 @@ class TestToken(OAuthTestCase):
client_secret=generate_key(),
authorization_flow=create_test_flow(),
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()
user = create_test_admin_user()
@ -230,7 +229,7 @@ class TestToken(OAuthTestCase):
self.assertNotIn("Access-Control-Allow-Credentials", response)
self.assertNotIn("Access-Control-Allow-Origin", response)
self.assertJSONEqual(
force_str(response.content),
response.content.decode(),
{
"access_token": new_token.access_token,
"refresh_token": new_token.refresh_token,
@ -250,7 +249,7 @@ class TestToken(OAuthTestCase):
client_secret=generate_key(),
authorization_flow=create_test_flow(),
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
self.app.provider = provider

View File

@ -3,7 +3,6 @@ import json
from dataclasses import asdict
from django.urls import reverse
from django.utils.encoding import force_str
from authentik.core.models import Application
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(),
authorization_flow=create_test_flow(),
redirect_uris="",
rsa_key=create_test_cert(),
signing_key=create_test_cert(),
)
self.provider.property_mappings.set(ScopeMapping.objects.all())
# 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}",
)
self.assertJSONEqual(
force_str(res.content),
res.content.decode(),
{
"name": self.user.name,
"given_name": self.user.name,
@ -77,7 +76,7 @@ class TestUserinfo(OAuthTestCase):
HTTP_AUTHORIZATION=f"Bearer {self.token.access_token}",
)
self.assertJSONEqual(
force_str(res.content),
res.content.decode(),
{
"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):
"""Validate that all required fields are set"""
key = provider.client_secret
if provider.jwt_alg == JWTAlgorithms.RS256:
key = provider.rsa_key.public_key
key, alg = provider.get_jwt_key()
if alg != JWTAlgorithms.HS256:
key = provider.signing_key.public_key
jwt = decode(
token.access_token,
key,
algorithms=[provider.jwt_alg],
algorithms=[alg],
audience=provider.client_id,
)
id_token = token.id_token.to_dict()

View File

@ -1,12 +1,17 @@
"""authentik OAuth2 JWKS Views"""
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.shortcuts import get_object_or_404
from django.views import View
from authentik.core.models import Application
from authentik.crypto.models import CertificateKeyPair
from authentik.providers.oauth2.models import JWTAlgorithms, OAuth2Provider
@ -25,22 +30,39 @@ class JWKSView(View):
"""Show RSA Key data for Provider"""
application = get_object_or_404(Application, slug=application_slug)
provider: OAuth2Provider = get_object_or_404(OAuth2Provider, pk=application.provider_id)
signing_key: CertificateKeyPair = provider.signing_key
response_data = {}
if provider.jwt_alg == JWTAlgorithms.RS256 and provider.rsa_key:
public_key: RSAPublicKey = provider.rsa_key.private_key.public_key()
public_numbers = public_key.public_numbers()
response_data["keys"] = [
{
"kty": "RSA",
"alg": "RS256",
"use": "sig",
"kid": provider.rsa_key.kid,
"n": b64_enc(public_numbers.n),
"e": b64_enc(public_numbers.e),
}
]
if signing_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()
response_data["keys"] = [
{
"kty": "RSA",
"alg": JWTAlgorithms.RS256,
"use": "sig",
"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),
"e": b64_enc(public_numbers.e),
}
]
response = JsonResponse(response_data)
response["Access-Control-Allow-Origin"] = "*"

View File

@ -39,6 +39,7 @@ class ProviderInfoView(View):
)
if SCOPE_OPENID not in scopes:
scopes.append(SCOPE_OPENID)
_, supported_alg = provider.get_jwt_key()
return {
"issuer": provider.get_issuer(self.request),
"authorization_endpoint": self.request.build_absolute_uri(
@ -78,7 +79,7 @@ class ProviderInfoView(View):
GRANT_TYPE_REFRESH_TOKEN,
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
"subject_types_supported": ["public"],
"token_endpoint_auth_methods_supported": [

View File

@ -2,6 +2,7 @@
import django.db.models.deletion
from django.apps.registry import Apps
from django.core.exceptions import FieldError
from django.db import migrations, models
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):
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
for provider in ProxyProvider.objects.using(db_alias).filter(jwt_alg=JWTAlgorithms.RS256):
provider.set_oauth_defaults()
provider.save()
try:
for provider in ProxyProvider.objects.using(db_alias).filter(jwt_alg=JWTAlgorithms.RS256):
provider.set_oauth_defaults()
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):

View File

@ -1,17 +1,23 @@
# Generated by Django 3.2.6 on 2021-09-09 11:24
from django.apps.registry import Apps
from django.core.exceptions import FieldError
from django.db import migrations
from django.db.backends.base.schema import 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
for provider in ProxyProvider.objects.using(db_alias).filter(jwt_alg=JWTAlgorithms.RS256):
provider.set_oauth_defaults()
provider.save()
try:
for provider in ProxyProvider.objects.using(db_alias).filter(jwt_alg=JWTAlgorithms.RS256):
provider.set_oauth_defaults()
provider.save()
except FieldError:
# If the jwt_alg field doesn't exist, just ignore this migration
pass
class Migration(migrations.Migration):

View File

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

View File

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

View File

@ -1,7 +1,7 @@
"""SAML AuthNRequest Parser and dataclass"""
from base64 import b64decode
from dataclasses import dataclass
from typing import Optional, Union
from typing import Optional
from urllib.parse import quote_plus
import xmlsec
@ -54,9 +54,7 @@ class AuthNRequestParser:
def __init__(self, provider: SAMLProvider):
self.provider = provider
def _parse_xml(
self, decoded_xml: Union[str, bytes], relay_state: Optional[str]
) -> AuthNRequest:
def _parse_xml(self, decoded_xml: str | bytes, relay_state: Optional[str]) -> AuthNRequest:
root = ElementTree.fromstring(decoded_xml)
# 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"""
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:
Event.new(EventAction.SYSTEM_EXCEPTION, message=exception_to_string(exception)).save()

View File

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

View File

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

View File

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

View File

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

View File

@ -58,6 +58,9 @@ class BaseOAuthClient:
args = self.get_redirect_args()
additional = parameters or {}
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)
LOGGER.info("redirect args", **args)
authorization_url = self.source.type.authorization_url or ""

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