Compare commits
85 Commits
version-0.
...
version/0.
Author | SHA1 | Date | |
---|---|---|---|
53d9092022 | |||
a6d0c8c26c | |||
5797a3743a | |||
b7e43efb34 | |||
48df12d045 | |||
4fea0f5939 | |||
a7bdd63e4d | |||
e216efb6ec | |||
378fe38b12 | |||
ce9fb8801c | |||
67ca83c228 | |||
ee2e737782 | |||
b04c9a2098 | |||
7f7b7e37c1 | |||
e7c96eb70d | |||
e8debce9c8 | |||
bcd0686a33 | |||
55322995a1 | |||
dff5eb69c8 | |||
b747022bc1 | |||
885fcff495 | |||
5b18e28753 | |||
9848c5f3eb | |||
fc98c3934a | |||
7964061466 | |||
5f90f54195 | |||
49eb568d3c | |||
d47d9103c7 | |||
12cbe464fc | |||
d17b2b0d1b | |||
f17d809219 | |||
6c8e9fb553 | |||
43bb29e16a | |||
29edbb0357 | |||
12ae867759 | |||
a20ca9136b | |||
3759e96e7d | |||
480d882a82 | |||
e5e1e3737d | |||
8dddcf891e | |||
319104c39b | |||
a9336f069c | |||
33f5169f36 | |||
4c690a20ef | |||
f68c8f7d90 | |||
95b56a0005 | |||
811c569b54 | |||
3ac3a8eebe | |||
6a5a243dac | |||
3549a9ecdd | |||
ee916a68a4 | |||
e9ca42cbb9 | |||
692d577217 | |||
f192ee5052 | |||
c95f8e8418 | |||
9549a7188b | |||
4998ccbe41 | |||
a56ddb2b8e | |||
3cc6b8ee38 | |||
927ab509a1 | |||
c85506f43c | |||
4157a0780d | |||
79da2bf698 | |||
c3e9168b46 | |||
d16838bbed | |||
6032efb67d | |||
322c6f01c2 | |||
71a58955f2 | |||
f035da440a | |||
001de38d85 | |||
3ea39fe122 | |||
7bfa217cae | |||
fdb9b45c51 | |||
116375084c | |||
1fca1df9dc | |||
4464ecc060 | |||
1af4373d97 | |||
28bbf5ac7f | |||
23f61e6b4f | |||
db135a6dbc | |||
a4dc6d13b5 | |||
4d88dcff08 | |||
6a835ad192 | |||
efc849e760 | |||
e62333dfb3 |
@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 0.13.3-stable
|
||||
current_version = 0.14.0-rc1
|
||||
tag = True
|
||||
commit = True
|
||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-(?P<release>.*)
|
||||
|
14
.github/workflows/release.yml
vendored
14
.github/workflows/release.yml
vendored
@ -18,11 +18,11 @@ jobs:
|
||||
- name: Building Docker Image
|
||||
run: docker build
|
||||
--no-cache
|
||||
-t beryju/authentik:0.13.3-stable
|
||||
-t beryju/authentik:0.14.0-rc1
|
||||
-t beryju/authentik:latest
|
||||
-f Dockerfile .
|
||||
- name: Push Docker Container to Registry (versioned)
|
||||
run: docker push beryju/authentik:0.13.3-stable
|
||||
run: docker push beryju/authentik:0.14.0-rc1
|
||||
- name: Push Docker Container to Registry (latest)
|
||||
run: docker push beryju/authentik:latest
|
||||
build-proxy:
|
||||
@ -48,11 +48,11 @@ jobs:
|
||||
cd proxy/
|
||||
docker build \
|
||||
--no-cache \
|
||||
-t beryju/authentik-proxy:0.13.3-stable \
|
||||
-t beryju/authentik-proxy:0.14.0-rc1 \
|
||||
-t beryju/authentik-proxy:latest \
|
||||
-f Dockerfile .
|
||||
- name: Push Docker Container to Registry (versioned)
|
||||
run: docker push beryju/authentik-proxy:0.13.3-stable
|
||||
run: docker push beryju/authentik-proxy:0.14.0-rc1
|
||||
- name: Push Docker Container to Registry (latest)
|
||||
run: docker push beryju/authentik-proxy:latest
|
||||
build-static:
|
||||
@ -69,11 +69,11 @@ jobs:
|
||||
cd web/
|
||||
docker build \
|
||||
--no-cache \
|
||||
-t beryju/authentik-static:0.13.3-stable \
|
||||
-t beryju/authentik-static:0.14.0-rc1 \
|
||||
-t beryju/authentik-static:latest \
|
||||
-f Dockerfile .
|
||||
- name: Push Docker Container to Registry (versioned)
|
||||
run: docker push beryju/authentik-static:0.13.3-stable
|
||||
run: docker push beryju/authentik-static:0.14.0-rc1
|
||||
- name: Push Docker Container to Registry (latest)
|
||||
run: docker push beryju/authentik-static:latest
|
||||
test-release:
|
||||
@ -107,5 +107,5 @@ jobs:
|
||||
SENTRY_PROJECT: authentik
|
||||
SENTRY_URL: https://sentry.beryju.org
|
||||
with:
|
||||
tagName: 0.13.3-stable
|
||||
tagName: 0.14.0-rc1
|
||||
environment: beryjuorg-prod
|
||||
|
687
LICENSE
687
LICENSE
@ -1,21 +1,674 @@
|
||||
MIT License
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (c) 2019 BeryJu.org
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
Preamble
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
|
195
Pipfile.lock
generated
195
Pipfile.lock
generated
@ -53,10 +53,10 @@
|
||||
},
|
||||
"autobahn": {
|
||||
"hashes": [
|
||||
"sha256:491238c31f78721eaa9d0593909ab455a4ea68127aadd76ecf67185143f5f298",
|
||||
"sha256:72b68a1ce1e10e3cbcc3b280aae86d5b2e7a1f409febab1ab91a8a3274113f6e"
|
||||
"sha256:410a93e0e29882c8b5d5ab05d220b07609b886ef5f23c0b8d39153254ffd6895",
|
||||
"sha256:52ee4236ff9a1fcbbd9500439dcf3284284b37f8a6b31ecc8a36e00cf9f95049"
|
||||
],
|
||||
"version": "==20.12.2"
|
||||
"version": "==20.12.3"
|
||||
},
|
||||
"automat": {
|
||||
"hashes": [
|
||||
@ -74,18 +74,18 @@
|
||||
},
|
||||
"boto3": {
|
||||
"hashes": [
|
||||
"sha256:a05614300fd404c7952a55ae92e106b9400ae65886425aaab3104527be833848",
|
||||
"sha256:c7556b0861d982b71043fbc0df024644320c817ad796391c442d0c2f15a77223"
|
||||
"sha256:0bb2c3159b9f5e0df50430bf06a155bd7f27f480825b6374dde807d42360a668",
|
||||
"sha256:a49b3ab4bfa2f6394ba60165cfc468410797dd410f32eed47e22f61451ee986e"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.16.39"
|
||||
"version": "==1.16.43"
|
||||
},
|
||||
"botocore": {
|
||||
"hashes": [
|
||||
"sha256:449e4196160ff58ee27d2a626a7ce4cfff2640fe1806d7a279e73a30ad286347",
|
||||
"sha256:e0d0386098a072abd7b6c087e6149d997377c969a823ebe01b3f5bfabe9bfac0"
|
||||
"sha256:7398c900dbd4e3d61647269215396ea3e8082f494f3e7b65d9b6aca049c1d463",
|
||||
"sha256:795a67338cadb0c3a45014a6c81659da6af623a4e973812f87a6f9d9fb7712e9"
|
||||
],
|
||||
"version": "==1.19.39"
|
||||
"version": "==1.19.43"
|
||||
},
|
||||
"cachetools": {
|
||||
"hashes": [
|
||||
@ -343,11 +343,11 @@
|
||||
},
|
||||
"django-storages": {
|
||||
"hashes": [
|
||||
"sha256:056ec3e9e2b0c6f363913976072ffba2923e79e4859578047da139ba1637497e",
|
||||
"sha256:7af56611c62a1c174aab4e862efb7fdd98296dccf76f42135f5b6851fc313c97"
|
||||
"sha256:c823dbf56c9e35b0999a13d7e05062b837bae36c518a40255d522fbe3750fbb4",
|
||||
"sha256:f28765826d507a0309cfaa849bd084894bc71d81bf0d09479168d44785396f80"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.11"
|
||||
"version": "==1.11.1"
|
||||
},
|
||||
"djangorestframework": {
|
||||
"hashes": [
|
||||
@ -366,11 +366,11 @@
|
||||
},
|
||||
"docker": {
|
||||
"hashes": [
|
||||
"sha256:317e95a48c32de8c1aac92a48066a5b73e218ed096e03758bcdd799a7130a1a1",
|
||||
"sha256:cffc771d4ea1389fc66bc95cb72d304aa41d1a1563482a9a000fba3a84ed5071"
|
||||
"sha256:0604a74719d5d2de438753934b755bfcda6f62f49b8e4b30969a4b0a2a8a1220",
|
||||
"sha256:e455fa49aabd4f22da9f4e1c1f9d16308286adc60abaf64bf3e1feafaed81d06"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==4.4.0"
|
||||
"version": "==4.4.1"
|
||||
},
|
||||
"drf-yasg2": {
|
||||
"hashes": [
|
||||
@ -646,46 +646,36 @@
|
||||
},
|
||||
"msgpack": {
|
||||
"hashes": [
|
||||
"sha256:01835e300967e5ad6fdbfc36eafe74df67ff47e16e0d6dee8766630550315903",
|
||||
"sha256:03c5554315317d76c25a15569dd52ac6047b105df71e861f24faf9675672b72d",
|
||||
"sha256:0968b368a9a9081435bfcb7a57a1e8b75c7bf038ef911b369acd2e038c7f873a",
|
||||
"sha256:1d7ab166401f7789bf11262439336c0a01b878f0d602e48f35c35d2e3a555820",
|
||||
"sha256:1e8d27bac821f8aa909904a704a67e5e8bc2e42b153415fc3621b7afbc06702b",
|
||||
"sha256:1fc9f21da9fd77088ebfd3c9941b044ca3f4a048e85f7ff5727f26bcdbffed61",
|
||||
"sha256:20196229acc193939223118c7420838749d5b0cece49cd397739a3a6ffcfe2d1",
|
||||
"sha256:2933443313289725f16bd7b99a8c3aa6a2cca1549e661d7407f056a0af80bf7b",
|
||||
"sha256:2966b155356fd231fa441131d7301e1596ee38974ad56dc57fd752fdbe2bb63f",
|
||||
"sha256:29a6fb3729215b6fcab786ef4f460a5406a5c056f7021191f70ff7712a3f6ba4",
|
||||
"sha256:35cbefa7d7bddfb4b0770a1b9ff721cd8dfe9a680947a68457974d5e3e6acc2f",
|
||||
"sha256:35ff1ac162a77fb78be360d9f771d36cbf1202e94fc6d70e284ad5db6ab72608",
|
||||
"sha256:40dd1ac7420f071e96b3e4a4a7b8e69546a6f8065ff5995dbacf53f86207eb98",
|
||||
"sha256:4bea1938e484c9caca9585105f447d6807c496c153b7244fa726b3cc4a68ec9e",
|
||||
"sha256:4e58b9f4a99bc3a90859bb006ec4422448a5ce39e5cd6e7498c56de5dcec9c34",
|
||||
"sha256:66d47e952856bfcde46d8351380d0b5b928a73112b66bc06d5367dfcc077c06a",
|
||||
"sha256:69f6aa503378548ea1e760c11aeb6fc91952bf3634fd806a38a0e47edb507fcd",
|
||||
"sha256:7033215267a0e9f60f4a5e4fb2228a932c404f237817caff8dc3115d9e7cd975",
|
||||
"sha256:7b50afd767cc053ad92fad39947c3670db27305fd1c49acded44d9d9ac8b56fd",
|
||||
"sha256:99ea9e65876546743b2b8bb5bc7adefbb03b9da78a899827467da197a48f790b",
|
||||
"sha256:abcc62303ac4d789878d4aac4cdba1bbe2adb478d67be99cd4a6d56ac3a4028f",
|
||||
"sha256:b107f9b36665bf7d7c6176a938a361a7aba16aa179d833919448f77287866484",
|
||||
"sha256:b5b27923b6c98a2616b7e906a29e4e10e1b4424aea87a0e0d5636327dc6ea315",
|
||||
"sha256:bf8eedc7bfbf63cbc9abe58287c32d78780a347835e82c23033c68f11f80bb05",
|
||||
"sha256:c144ff4954a6ea40aa603600c8be175349588fc68696092889fa34ab6e055060",
|
||||
"sha256:c4e5f96a1d0d916ce7a16decb7499e8923ddef007cf7d68412fb68767766648a",
|
||||
"sha256:c60e8b2bf754b8dcc1075c5bee0b177ed9193e7cbd2377faaf507120a948e697",
|
||||
"sha256:c82fc6cdba5737eb6ed0c926a30a5d56e7b050297375a16d6c5ad89b576fd979",
|
||||
"sha256:ce4ebe2c79411cd5671b20862831880e7850a2de699cff6626f48853fde61ae6",
|
||||
"sha256:d113c6b1239c62669ef3063693842605a3edbfebc39a333cf91ba60d314afe6d",
|
||||
"sha256:d3cea07ad16919a44e8d1ea67efa5244855cdce807d672f41694acc24d08834e",
|
||||
"sha256:d76672602db16e3f44bc1a85c7ee5f15d79e02fcf5bc9d133c2954753be6eddc",
|
||||
"sha256:decf2091b75987ca2564e3b742f9614eb7d57e39ff04eaa68af7a3fc5648f7ed",
|
||||
"sha256:e13b9007af66a3f62574bc0a13843df0e4402f5ee4b00a02aa1803f01d26b9fb",
|
||||
"sha256:e157edf4213dacafb0f862e0b7a3a18448250cec91aa1334f432f49028acc650",
|
||||
"sha256:e234ff83628ca3ab345bf97fb36ccbf6d2f1700f5e08868643bf4489edc960f8",
|
||||
"sha256:f08d9dd3ce0c5e972dc4653f0fb66d2703941e65356388c13032b578dd718261",
|
||||
"sha256:f20d7d4f1f0728560408ba6933154abccf0c20f24642a2404b43d5c23e4119ab"
|
||||
"sha256:0cb94ee48675a45d3b86e61d13c1e6f1696f0183f0715544976356ff86f741d9",
|
||||
"sha256:1026dcc10537d27dd2d26c327e552f05ce148977e9d7b9f1718748281b38c841",
|
||||
"sha256:26a1759f1a88df5f1d0b393eb582ec022326994e311ba9c5818adc5374736439",
|
||||
"sha256:2a5866bdc88d77f6e1370f82f2371c9bc6fc92fe898fa2dec0c5d4f5435a2694",
|
||||
"sha256:31c17bbf2ae5e29e48d794c693b7ca7a0c73bd4280976d408c53df421e838d2a",
|
||||
"sha256:497d2c12426adcd27ab83144057a705efb6acc7e85957a51d43cdcf7f258900f",
|
||||
"sha256:5a9ee2540c78659a1dd0b110f73773533ee3108d4e1219b5a15a8d635b7aca0e",
|
||||
"sha256:8521e5be9e3b93d4d5e07cb80b7e32353264d143c1f072309e1863174c6aadb1",
|
||||
"sha256:87869ba567fe371c4555d2e11e4948778ab6b59d6cc9d8460d543e4cfbbddd1c",
|
||||
"sha256:8ffb24a3b7518e843cd83538cf859e026d24ec41ac5721c18ed0c55101f9775b",
|
||||
"sha256:92be4b12de4806d3c36810b0fe2aeedd8d493db39e2eb90742b9c09299eb5759",
|
||||
"sha256:9ea52fff0473f9f3000987f313310208c879493491ef3ccf66268eff8d5a0326",
|
||||
"sha256:a4355d2193106c7aa77c98fc955252a737d8550320ecdb2e9ac701e15e2943bc",
|
||||
"sha256:a99b144475230982aee16b3d249170f1cccebf27fb0a08e9f603b69637a62192",
|
||||
"sha256:ac25f3e0513f6673e8b405c3a80500eb7be1cf8f57584be524c4fa78fe8e0c83",
|
||||
"sha256:b28c0876cce1466d7c2195d7658cf50e4730667196e2f1355c4209444717ee06",
|
||||
"sha256:b55f7db883530b74c857e50e149126b91bb75d35c08b28db12dcb0346f15e46e",
|
||||
"sha256:b6d9e2dae081aa35c44af9c4298de4ee72991305503442a5c74656d82b581fe9",
|
||||
"sha256:c747c0cc08bd6d72a586310bda6ea72eeb28e7505990f342552315b229a19b33",
|
||||
"sha256:d6c64601af8f3893d17ec233237030e3110f11b8a962cb66720bf70c0141aa54",
|
||||
"sha256:d8167b84af26654c1124857d71650404336f4eb5cc06900667a493fc619ddd9f",
|
||||
"sha256:de6bd7990a2c2dabe926b7e62a92886ccbf809425c347ae7de277067f97c2887",
|
||||
"sha256:e36a812ef4705a291cdb4a2fd352f013134f26c6ff63477f20235138d1d21009",
|
||||
"sha256:e89ec55871ed5473a041c0495b7b4e6099f6263438e0bd04ccd8418f92d5d7f2",
|
||||
"sha256:f3e6aaf217ac1c7ce1563cf52a2f4f5d5b1f64e8729d794165db71da57257f0c",
|
||||
"sha256:f484cd2dca68502de3704f056fa9b318c94b1539ed17a4c784266df5d6978c87",
|
||||
"sha256:fae04496f5bc150eefad4e9571d1a76c55d021325dcd484ce45065ebbdd00984",
|
||||
"sha256:fe07bc6735d08e492a327f496b7850e98cb4d112c56df69b0c844dbebcbb47f6"
|
||||
],
|
||||
"version": "==1.0.1"
|
||||
"version": "==1.0.2"
|
||||
},
|
||||
"oauthlib": {
|
||||
"hashes": [
|
||||
@ -1064,10 +1054,10 @@
|
||||
},
|
||||
"txaio": {
|
||||
"hashes": [
|
||||
"sha256:17938f2bca4a9cabce61346758e482ca4e600160cbc28e861493eac74a19539d",
|
||||
"sha256:38a469daf93c37e5527cb062653d6393ae11663147c42fab7ddc3f6d00d434ae"
|
||||
"sha256:1488d31d564a116538cc1265ac3f7979fb6223bb5a9e9f1479436ee2c17d8549",
|
||||
"sha256:a8676d6c68aea1f0e2548c4afdb8e6253873af3bc2659bb5bcd9f39dff7ff90f"
|
||||
],
|
||||
"version": "==20.4.1"
|
||||
"version": "==20.12.1"
|
||||
},
|
||||
"uritemplate": {
|
||||
"hashes": [
|
||||
@ -1093,11 +1083,11 @@
|
||||
"standard"
|
||||
],
|
||||
"hashes": [
|
||||
"sha256:2a7b17f4d9848d6557ccc2274a5f7c97f1daf037d130a0c6918f67cd9bc8cdf5",
|
||||
"sha256:6fcce74c00b77d4f4b3ed7ba1b2a370d27133bfdb46f835b7a76dfe0a8c110ae"
|
||||
"sha256:6707fa7f4dbd86fd6982a2d4ecdaad2704e4514d23a1e4278104311288b04691",
|
||||
"sha256:d19ca083bebd212843e01f689900e5c637a292c63bb336c7f0735a99300a5f38"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.13.1"
|
||||
"version": "==0.13.2"
|
||||
},
|
||||
"uvloop": {
|
||||
"hashes": [
|
||||
@ -1328,43 +1318,58 @@
|
||||
},
|
||||
"coverage": {
|
||||
"hashes": [
|
||||
"sha256:0203acd33d2298e19b57451ebb0bed0ab0c602e5cf5a818591b4918b1f97d516",
|
||||
"sha256:0f313707cdecd5cd3e217fc68c78a960b616604b559e9ea60cc16795c4304259",
|
||||
"sha256:1c6703094c81fa55b816f5ae542c6ffc625fec769f22b053adb42ad712d086c9",
|
||||
"sha256:1d44bb3a652fed01f1f2c10d5477956116e9b391320c94d36c6bf13b088a1097",
|
||||
"sha256:280baa8ec489c4f542f8940f9c4c2181f0306a8ee1a54eceba071a449fb870a0",
|
||||
"sha256:29a6272fec10623fcbe158fdf9abc7a5fa032048ac1d8631f14b50fbfc10d17f",
|
||||
"sha256:2b31f46bf7b31e6aa690d4c7a3d51bb262438c6dcb0d528adde446531d0d3bb7",
|
||||
"sha256:2d43af2be93ffbad25dd959899b5b809618a496926146ce98ee0b23683f8c51c",
|
||||
"sha256:381ead10b9b9af5f64646cd27107fb27b614ee7040bb1226f9c07ba96625cbb5",
|
||||
"sha256:47a11bdbd8ada9b7ee628596f9d97fbd3851bd9999d398e9436bd67376dbece7",
|
||||
"sha256:4d6a42744139a7fa5b46a264874a781e8694bb32f1d76d8137b68138686f1729",
|
||||
"sha256:50691e744714856f03a86df3e2bff847c2acede4c191f9a1da38f088df342978",
|
||||
"sha256:530cc8aaf11cc2ac7430f3614b04645662ef20c348dce4167c22d99bec3480e9",
|
||||
"sha256:582ddfbe712025448206a5bc45855d16c2e491c2dd102ee9a2841418ac1c629f",
|
||||
"sha256:63808c30b41f3bbf65e29f7280bf793c79f54fb807057de7e5238ffc7cc4d7b9",
|
||||
"sha256:71b69bd716698fa62cd97137d6f2fdf49f534decb23a2c6fc80813e8b7be6822",
|
||||
"sha256:7858847f2d84bf6e64c7f66498e851c54de8ea06a6f96a32a1d192d846734418",
|
||||
"sha256:78e93cc3571fd928a39c0b26767c986188a4118edc67bc0695bc7a284da22e82",
|
||||
"sha256:7f43286f13d91a34fadf61ae252a51a130223c52bfefb50310d5b2deb062cf0f",
|
||||
"sha256:86e9f8cd4b0cdd57b4ae71a9c186717daa4c5a99f3238a8723f416256e0b064d",
|
||||
"sha256:8f264ba2701b8c9f815b272ad568d555ef98dfe1576802ab3149c3629a9f2221",
|
||||
"sha256:9342dd70a1e151684727c9c91ea003b2fb33523bf19385d4554f7897ca0141d4",
|
||||
"sha256:9361de40701666b034c59ad9e317bae95c973b9ff92513dd0eced11c6adf2e21",
|
||||
"sha256:9669179786254a2e7e57f0ecf224e978471491d660aaca833f845b72a2df3709",
|
||||
"sha256:aac1ba0a253e17889550ddb1b60a2063f7474155465577caa2a3b131224cfd54",
|
||||
"sha256:aef72eae10b5e3116bac6957de1df4d75909fc76d1499a53fb6387434b6bcd8d",
|
||||
"sha256:bd3166bb3b111e76a4f8e2980fa1addf2920a4ca9b2b8ca36a3bc3dedc618270",
|
||||
"sha256:c1b78fb9700fc961f53386ad2fd86d87091e06ede5d118b8a50dea285a071c24",
|
||||
"sha256:c3888a051226e676e383de03bf49eb633cd39fc829516e5334e69b8d81aae751",
|
||||
"sha256:c5f17ad25d2c1286436761b462e22b5020d83316f8e8fcb5deb2b3151f8f1d3a",
|
||||
"sha256:c851b35fc078389bc16b915a0a7c1d5923e12e2c5aeec58c52f4aa8085ac8237",
|
||||
"sha256:cb7df71de0af56000115eafd000b867d1261f786b5eebd88a0ca6360cccfaca7",
|
||||
"sha256:cedb2f9e1f990918ea061f28a0f0077a07702e3819602d3507e2ff98c8d20636",
|
||||
"sha256:e8caf961e1b1a945db76f1b5fa9c91498d15f545ac0ababbe575cfab185d3bd8"
|
||||
"sha256:08b3ba72bd981531fd557f67beee376d6700fba183b167857038997ba30dd297",
|
||||
"sha256:2757fa64e11ec12220968f65d086b7a29b6583d16e9a544c889b22ba98555ef1",
|
||||
"sha256:3102bb2c206700a7d28181dbe04d66b30780cde1d1c02c5f3c165cf3d2489497",
|
||||
"sha256:3498b27d8236057def41de3585f317abae235dd3a11d33e01736ffedb2ef8606",
|
||||
"sha256:378ac77af41350a8c6b8801a66021b52da8a05fd77e578b7380e876c0ce4f528",
|
||||
"sha256:38f16b1317b8dd82df67ed5daa5f5e7c959e46579840d77a67a4ceb9cef0a50b",
|
||||
"sha256:3911c2ef96e5ddc748a3c8b4702c61986628bb719b8378bf1e4a6184bbd48fe4",
|
||||
"sha256:3a3c3f8863255f3c31db3889f8055989527173ef6192a283eb6f4db3c579d830",
|
||||
"sha256:3b14b1da110ea50c8bcbadc3b82c3933974dbeea1832e814aab93ca1163cd4c1",
|
||||
"sha256:535dc1e6e68fad5355f9984d5637c33badbdc987b0c0d303ee95a6c979c9516f",
|
||||
"sha256:6f61319e33222591f885c598e3e24f6a4be3533c1d70c19e0dc59e83a71ce27d",
|
||||
"sha256:723d22d324e7997a651478e9c5a3120a0ecbc9a7e94071f7e1954562a8806cf3",
|
||||
"sha256:76b2775dda7e78680d688daabcb485dc87cf5e3184a0b3e012e1d40e38527cc8",
|
||||
"sha256:782a5c7df9f91979a7a21792e09b34a658058896628217ae6362088b123c8500",
|
||||
"sha256:7e4d159021c2029b958b2363abec4a11db0ce8cd43abb0d9ce44284cb97217e7",
|
||||
"sha256:8dacc4073c359f40fcf73aede8428c35f84639baad7e1b46fce5ab7a8a7be4bb",
|
||||
"sha256:8f33d1156241c43755137288dea619105477961cfa7e47f48dbf96bc2c30720b",
|
||||
"sha256:8ffd4b204d7de77b5dd558cdff986a8274796a1e57813ed005b33fd97e29f059",
|
||||
"sha256:93a280c9eb736a0dcca19296f3c30c720cb41a71b1f9e617f341f0a8e791a69b",
|
||||
"sha256:9a4f66259bdd6964d8cf26142733c81fb562252db74ea367d9beb4f815478e72",
|
||||
"sha256:9a9d4ff06804920388aab69c5ea8a77525cf165356db70131616acd269e19b36",
|
||||
"sha256:a2070c5affdb3a5e751f24208c5c4f3d5f008fa04d28731416e023c93b275277",
|
||||
"sha256:a4857f7e2bc6921dbd487c5c88b84f5633de3e7d416c4dc0bb70256775551a6c",
|
||||
"sha256:a607ae05b6c96057ba86c811d9c43423f35e03874ffb03fbdcd45e0637e8b631",
|
||||
"sha256:a66ca3bdf21c653e47f726ca57f46ba7fc1f260ad99ba783acc3e58e3ebdb9ff",
|
||||
"sha256:ab110c48bc3d97b4d19af41865e14531f300b482da21783fdaacd159251890e8",
|
||||
"sha256:b239711e774c8eb910e9b1ac719f02f5ae4bf35fa0420f438cdc3a7e4e7dd6ec",
|
||||
"sha256:be0416074d7f253865bb67630cf7210cbc14eb05f4099cc0f82430135aaa7a3b",
|
||||
"sha256:c46643970dff9f5c976c6512fd35768c4a3819f01f61169d8cdac3f9290903b7",
|
||||
"sha256:c5ec71fd4a43b6d84ddb88c1df94572479d9a26ef3f150cef3dacefecf888105",
|
||||
"sha256:c6e5174f8ca585755988bc278c8bb5d02d9dc2e971591ef4a1baabdf2d99589b",
|
||||
"sha256:c89b558f8a9a5a6f2cfc923c304d49f0ce629c3bd85cb442ca258ec20366394c",
|
||||
"sha256:cc44e3545d908ecf3e5773266c487ad1877be718d9dc65fc7eb6e7d14960985b",
|
||||
"sha256:cc6f8246e74dd210d7e2b56c76ceaba1cc52b025cd75dbe96eb48791e0250e98",
|
||||
"sha256:cd556c79ad665faeae28020a0ab3bda6cd47d94bec48e36970719b0b86e4dcf4",
|
||||
"sha256:ce6f3a147b4b1a8b09aae48517ae91139b1b010c5f36423fa2b866a8b23df879",
|
||||
"sha256:ceb499d2b3d1d7b7ba23abe8bf26df5f06ba8c71127f188333dddcf356b4b63f",
|
||||
"sha256:cef06fb382557f66d81d804230c11ab292d94b840b3cb7bf4450778377b592f4",
|
||||
"sha256:e448f56cfeae7b1b3b5bcd99bb377cde7c4eb1970a525c770720a352bc4c8044",
|
||||
"sha256:e52d3d95df81c8f6b2a1685aabffadf2d2d9ad97203a40f8d61e51b70f191e4e",
|
||||
"sha256:ee2f1d1c223c3d2c24e3afbb2dd38be3f03b1a8d6a83ee3d9eb8c36a52bee899",
|
||||
"sha256:f2c6888eada180814b8583c3e793f3f343a692fc802546eed45f40a001b1169f",
|
||||
"sha256:f51dbba78d68a44e99d484ca8c8f604f17e957c1ca09c3ebc2c7e3bbd9ba0448",
|
||||
"sha256:f54de00baf200b4539a5a092a759f000b5f45fd226d6d25a76b0dff71177a714",
|
||||
"sha256:fa10fee7e32213f5c7b0d6428ea92e3a3fdd6d725590238a3f92c0de1c78b9d2",
|
||||
"sha256:fabeeb121735d47d8eab8671b6b031ce08514c86b7ad8f7d5490a7b6dcd6267d",
|
||||
"sha256:fac3c432851038b3e6afe086f777732bcf7f6ebbfd90951fa04ee53db6d0bcdd",
|
||||
"sha256:fda29412a66099af6d6de0baa6bd7c52674de177ec2ad2630ca264142d69c6c7",
|
||||
"sha256:ff1330e8bc996570221b450e2d539134baa9465f5cb98aff0e0f73f34172e0ae"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==5.3"
|
||||
"version": "==5.3.1"
|
||||
},
|
||||
"django": {
|
||||
"hashes": [
|
||||
|
@ -1,4 +1,4 @@
|
||||
<img src="web/icons/icon_top_brand.svg" height="250" alt="authentik logo">
|
||||
<img src="https://goauthentik.io/img/icon_top_brand_colour.svg" height="250" alt="authentik logo">
|
||||
|
||||
---
|
||||
|
||||
@ -21,8 +21,8 @@ For bigger setups, there is a Helm Chart in the `helm/` directory. This is docum
|
||||
|
||||
## Screenshots
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## Development
|
||||
|
||||
|
@ -1,2 +1,2 @@
|
||||
"""authentik"""
|
||||
__version__ = "0.13.3-stable"
|
||||
__version__ = "0.14.0-rc1"
|
||||
|
@ -4,10 +4,9 @@ from collections import Counter
|
||||
from datetime import timedelta
|
||||
from typing import Dict, List
|
||||
|
||||
from django.db.models import Count, ExpressionWrapper, F
|
||||
from django.db.models import Count, ExpressionWrapper, F, Model
|
||||
from django.db.models.fields import DurationField
|
||||
from django.db.models.functions import ExtractHour
|
||||
from django.http import response
|
||||
from django.utils.timezone import now
|
||||
from drf_yasg2.utils import swagger_auto_schema
|
||||
from rest_framework.fields import SerializerMethodField
|
||||
@ -17,7 +16,7 @@ from rest_framework.response import Response
|
||||
from rest_framework.serializers import Serializer
|
||||
from rest_framework.viewsets import ViewSet
|
||||
|
||||
from authentik.audit.models import Event, EventAction
|
||||
from authentik.events.models import Event, EventAction
|
||||
|
||||
|
||||
def get_events_per_1h(**filter_kwargs) -> List[Dict[str, int]]:
|
||||
@ -60,10 +59,10 @@ class AdministrationMetricsSerializer(Serializer):
|
||||
"""Get failed logins per hour for the last 24 hours"""
|
||||
return get_events_per_1h(action=EventAction.LOGIN_FAILED)
|
||||
|
||||
def create(self, request: Request) -> response:
|
||||
def create(self, validated_data: dict) -> Model:
|
||||
raise NotImplementedError
|
||||
|
||||
def update(self, request: Request) -> Response:
|
||||
def update(self, instance: Model, validated_data: dict) -> Model:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
|
@ -2,6 +2,7 @@
|
||||
from importlib import import_module
|
||||
|
||||
from django.contrib import messages
|
||||
from django.db.models import Model
|
||||
from django.http.response import Http404
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from drf_yasg2.utils import swagger_auto_schema
|
||||
@ -26,10 +27,10 @@ class TaskSerializer(Serializer):
|
||||
status = IntegerField(source="result.status.value")
|
||||
messages = ListField(source="result.messages")
|
||||
|
||||
def create(self, request: Request) -> Response:
|
||||
def create(self, validated_data: dict) -> Model:
|
||||
raise NotImplementedError
|
||||
|
||||
def update(self, request: Request) -> Response:
|
||||
def update(self, instance: Model, validated_data: dict) -> Model:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
"""authentik administration overview"""
|
||||
from django.core.cache import cache
|
||||
from django.db.models import Model
|
||||
from drf_yasg2.utils import swagger_auto_schema
|
||||
from packaging.version import parse
|
||||
from rest_framework.fields import SerializerMethodField
|
||||
@ -39,10 +40,10 @@ class VersionSerializer(Serializer):
|
||||
self.get_version_latest(instance)
|
||||
)
|
||||
|
||||
def create(self, request: Request) -> Response:
|
||||
def create(self, validated_data: dict) -> Model:
|
||||
raise NotImplementedError
|
||||
|
||||
def update(self, request: Request) -> Response:
|
||||
def update(self, instance: Model, validated_data: dict) -> Model:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@ -51,7 +52,7 @@ class VersionViewSet(ListModelMixin, GenericViewSet):
|
||||
|
||||
permission_classes = [IsAdminUser]
|
||||
|
||||
def get_queryset(self):
|
||||
def get_queryset(self): # pragma: no cover
|
||||
return None
|
||||
|
||||
@swagger_auto_schema(responses={200: VersionSerializer(many=True)})
|
||||
|
@ -15,7 +15,7 @@ class WorkerViewSet(ListModelMixin, GenericViewSet):
|
||||
serializer_class = Serializer
|
||||
permission_classes = [IsAdminUser]
|
||||
|
||||
def get_queryset(self):
|
||||
def get_queryset(self): # pragma: no cover
|
||||
return None
|
||||
|
||||
def list(self, request: Request) -> Response:
|
||||
|
@ -14,4 +14,6 @@ SOURCE_SERIALIZER_FIELDS = [
|
||||
"enabled",
|
||||
"authentication_flow",
|
||||
"enrollment_flow",
|
||||
"verbose_name",
|
||||
"verbose_name_plural",
|
||||
]
|
||||
|
@ -1,8 +1,11 @@
|
||||
"""authentik admin tasks"""
|
||||
from django.core.cache import cache
|
||||
from packaging.version import parse
|
||||
from requests import RequestException, get
|
||||
from structlog import get_logger
|
||||
|
||||
from authentik import __version__
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.tasks import MonitoredTask, TaskResult, TaskResultStatus
|
||||
from authentik.root.celery import CELERY_APP
|
||||
|
||||
@ -19,12 +22,24 @@ def update_latest_version(self: MonitoredTask):
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
tag_name = data.get("tag_name")
|
||||
cache.set(VERSION_CACHE_KEY, tag_name.split("/")[1], VERSION_CACHE_TIMEOUT)
|
||||
upstream_version = tag_name.split("/")[1]
|
||||
cache.set(VERSION_CACHE_KEY, upstream_version, VERSION_CACHE_TIMEOUT)
|
||||
self.set_status(
|
||||
TaskResult(
|
||||
TaskResultStatus.SUCCESSFUL, ["Successfully updated latest Version"]
|
||||
)
|
||||
)
|
||||
# Check if upstream version is newer than what we're running,
|
||||
# and if no event exists yet, create one.
|
||||
local_version = parse(__version__)
|
||||
if local_version < parse(upstream_version):
|
||||
# Event has already been created, don't create duplicate
|
||||
if Event.objects.filter(
|
||||
action=EventAction.UPDATE_AVAILABLE,
|
||||
context__new_version=upstream_version,
|
||||
).exists():
|
||||
return
|
||||
Event.new(EventAction.UPDATE_AVAILABLE, new_version=upstream_version).save()
|
||||
except (RequestException, IndexError) as exc:
|
||||
cache.set(VERSION_CACHE_KEY, "0.0.0", VERSION_CACHE_TIMEOUT)
|
||||
self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc))
|
||||
|
@ -1,131 +0,0 @@
|
||||
{% extends "administration/base.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
{% load authentik_utils %}
|
||||
|
||||
{% block content %}
|
||||
<section class="pf-c-page__main-section pf-m-light">
|
||||
<div class="pf-c-content">
|
||||
<h1>
|
||||
<i class="pf-icon pf-icon-applications"></i>
|
||||
{% trans 'Applications' %}
|
||||
</h1>
|
||||
<p>{% trans "External Applications which use authentik as Identity-Provider, utilizing protocols like OAuth2 and SAML." %}</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="pf-c-page__main-section pf-m-no-padding-mobile">
|
||||
<div class="pf-c-card">
|
||||
{% if object_list %}
|
||||
<div class="pf-c-toolbar">
|
||||
<div class="pf-c-toolbar__content">
|
||||
{% include 'partials/toolbar_search.html' %}
|
||||
<div class="pf-c-toolbar__bulk-select">
|
||||
<ak-modal-button href="{% url 'authentik_admin:application-create' %}">
|
||||
<ak-spinner-button slot="trigger" class="pf-m-primary">
|
||||
{% trans 'Create' %}
|
||||
</ak-spinner-button>
|
||||
<div slot="modal"></div>
|
||||
</ak-modal-button>
|
||||
<button role="ak-refresh" class="pf-c-button pf-m-primary">
|
||||
{% trans 'Refresh' %}
|
||||
</button>
|
||||
</div>
|
||||
{% include 'partials/pagination.html' %}
|
||||
</div>
|
||||
</div>
|
||||
<table class="pf-c-table pf-m-compact pf-m-grid-xl" role="grid">
|
||||
<thead>
|
||||
<tr role="row">
|
||||
<th role="columnheader"></th>
|
||||
<th role="columnheader" scope="col">{% trans 'Name' %}</th>
|
||||
<th role="columnheader" scope="col">{% trans 'Slug' %}</th>
|
||||
<th role="columnheader" scope="col">{% trans 'Provider' %}</th>
|
||||
<th role="columnheader" scope="col">{% trans 'Provider Type' %}</th>
|
||||
<th role="columnheader"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody role="rowgroup">
|
||||
{% for application in object_list %}
|
||||
<tr role="row">
|
||||
<td role="cell" {% if application.meta_icon %} style="vertical-align: bottom;" {% endif %}>
|
||||
{% if application.meta_icon %}
|
||||
<img class="app-icon pf-c-avatar" src="{{ application.meta_icon.url }}" alt="{% trans 'Application Icon' %}">
|
||||
{% else %}
|
||||
<i class="pf-icon pf-icon-arrow"></i>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td role="cell">
|
||||
<a href="/applications/{{ application.slug }}/">
|
||||
<div>
|
||||
{{ application.name }}
|
||||
</div>
|
||||
{% if application.meta_publisher %}
|
||||
<small>{{ application.meta_publisher }}</small>
|
||||
{% endif %}
|
||||
</a>
|
||||
</td>
|
||||
<td role="cell">
|
||||
<code>{{ application.slug }}</span>
|
||||
</td>
|
||||
<td role="cell">
|
||||
<span>
|
||||
{{ application.get_provider }}
|
||||
</span>
|
||||
</td>
|
||||
<td role="cell">
|
||||
<span>
|
||||
{{ application.get_provider|verbose_name }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<ak-modal-button href="{% url 'authentik_admin:application-update' pk=application.pk %}">
|
||||
<ak-spinner-button slot="trigger" class="pf-m-secondary">
|
||||
{% trans 'Edit' %}
|
||||
</ak-spinner-button>
|
||||
<div slot="modal"></div>
|
||||
</ak-modal-button>
|
||||
<ak-modal-button href="{% url 'authentik_admin:application-delete' pk=application.pk %}">
|
||||
<ak-spinner-button slot="trigger" class="pf-m-danger">
|
||||
{% trans 'Delete' %}
|
||||
</ak-spinner-button>
|
||||
<div slot="modal"></div>
|
||||
</ak-modal-button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="pf-c-pagination pf-m-bottom">
|
||||
{% include 'partials/pagination.html' %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="pf-c-toolbar">
|
||||
<div class="pf-c-toolbar__content">
|
||||
{% include 'partials/toolbar_search.html' %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="pf-c-empty-state">
|
||||
<div class="pf-c-empty-state__content">
|
||||
<i class="pf-icon pf-icon-applications pf-c-empty-state__icon" aria-hidden="true"></i>
|
||||
<h1 class="pf-c-title pf-m-lg">
|
||||
{% trans 'No Applications.' %}
|
||||
</h1>
|
||||
<div class="pf-c-empty-state__body">
|
||||
{% if request.GET.search != "" %}
|
||||
{% trans "Your search query doesn't match any application." %}
|
||||
{% else %}
|
||||
{% trans 'Currently no applications exist. Click the button below to create one.' %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<ak-modal-button href="{% url 'authentik_admin:application-create' %}">
|
||||
<ak-spinner-button slot="trigger" class="pf-m-primary">
|
||||
{% trans 'Create' %}
|
||||
</ak-spinner-button>
|
||||
<div slot="modal"></div>
|
||||
</ak-modal-button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
@ -53,7 +53,7 @@
|
||||
{% for flow in object_list %}
|
||||
<tr role="row">
|
||||
<th role="columnheader">
|
||||
<a href="/flows/{{ flow.slug }}/">
|
||||
<a href="/flows/{{ flow.slug }}">
|
||||
<div><code>{{ flow.slug }}</code></div>
|
||||
<small>{{ flow.name }}</small>
|
||||
</a>
|
||||
|
@ -81,7 +81,7 @@
|
||||
<div slot="modal"></div>
|
||||
</ak-modal-button>
|
||||
<ak-modal-button href="{% url 'authentik_admin:policy-test' pk=policy.pk %}">
|
||||
<ak-spinner-button slot="trigger" class="pf-m-tertiary">
|
||||
<ak-spinner-button slot="trigger" class="pf-m-secondary">
|
||||
{% trans 'Test' %}
|
||||
</ak-spinner-button>
|
||||
<div slot="modal"></div>
|
||||
|
@ -41,6 +41,17 @@
|
||||
</ak-modal-button>
|
||||
</li>
|
||||
{% endfor %}
|
||||
<li>
|
||||
<ak-modal-button href="{% url 'authentik_admin:provider-saml-from-metadata' %}">
|
||||
<button slot="trigger" class="pf-c-dropdown__menu-item">
|
||||
{% trans 'SAML Provider from Metadata' %}<br>
|
||||
<small>
|
||||
{% trans "Create a SAML Provider by importing its Metadata." %}
|
||||
</small>
|
||||
</button>
|
||||
<div slot="modal"></div>
|
||||
</ak-modal-button>
|
||||
</li>
|
||||
</ul>
|
||||
</ak-dropdown>
|
||||
<button role="ak-refresh" class="pf-c-button pf-m-primary">
|
||||
|
@ -37,8 +37,9 @@
|
||||
<table class="pf-c-table pf-m-compact pf-m-grid-xl" role="grid">
|
||||
<thead>
|
||||
<tr role="row">
|
||||
<th role="columnheader" scope="col">{% trans 'ID' %}</th>
|
||||
<th role="columnheader" scope="col">{% trans 'Created by' %}</th>
|
||||
<th role="columnheader" scope="col">{% trans 'Expiry' %}</th>
|
||||
<th role="columnheader" scope="col">{% trans 'Link' %}</th>
|
||||
<th role="cell"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -47,12 +48,17 @@
|
||||
<tr role="row">
|
||||
<td role="cell">
|
||||
<span>
|
||||
{{ invitation.expiry }}
|
||||
{{ invitation.invite_uuid }}
|
||||
</span>
|
||||
</td>
|
||||
<td role="cell">
|
||||
<span>
|
||||
{{ invitation.Link }}
|
||||
{{ invitation.created_by }}
|
||||
</span>
|
||||
</td>
|
||||
<td role="cell">
|
||||
<span>
|
||||
{{ invitation.expiry|default:"-" }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
|
76
authentik/admin/tests/test_tasks.py
Normal file
76
authentik/admin/tests/test_tasks.py
Normal file
@ -0,0 +1,76 @@
|
||||
"""test admin tasks"""
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase
|
||||
from requests.exceptions import RequestException
|
||||
|
||||
from authentik.admin.tasks import VERSION_CACHE_KEY, update_latest_version
|
||||
from authentik.events.models import Event, EventAction
|
||||
|
||||
|
||||
@dataclass
|
||||
class MockResponse:
|
||||
"""Mock class to emulate the methods of requests's Response we need"""
|
||||
|
||||
status_code: int
|
||||
response: str
|
||||
|
||||
def json(self) -> dict:
|
||||
"""Get json parsed response"""
|
||||
return json.loads(self.response)
|
||||
|
||||
def raise_for_status(self):
|
||||
"""raise RequestException if status code is 400 or more"""
|
||||
if self.status_code >= 400:
|
||||
raise RequestException
|
||||
|
||||
|
||||
REQUEST_MOCK_VALID = Mock(
|
||||
return_value=MockResponse(
|
||||
200,
|
||||
"""{
|
||||
"tag_name": "version/1.2.3"
|
||||
}""",
|
||||
)
|
||||
)
|
||||
|
||||
REQUEST_MOCK_INVALID = Mock(return_value=MockResponse(400, "{}"))
|
||||
|
||||
|
||||
class TestAdminTasks(TestCase):
|
||||
"""test admin tasks"""
|
||||
|
||||
@patch("authentik.admin.tasks.get", REQUEST_MOCK_VALID)
|
||||
def test_version_valid_response(self):
|
||||
"""Test Update checker with valid response"""
|
||||
update_latest_version.delay().get()
|
||||
self.assertEqual(cache.get(VERSION_CACHE_KEY), "1.2.3")
|
||||
self.assertTrue(
|
||||
Event.objects.filter(
|
||||
action=EventAction.UPDATE_AVAILABLE, context__new_version="1.2.3"
|
||||
).exists()
|
||||
)
|
||||
# test that a consecutive check doesn't create a duplicate event
|
||||
update_latest_version.delay().get()
|
||||
self.assertEqual(
|
||||
len(
|
||||
Event.objects.filter(
|
||||
action=EventAction.UPDATE_AVAILABLE, context__new_version="1.2.3"
|
||||
)
|
||||
),
|
||||
1,
|
||||
)
|
||||
|
||||
@patch("authentik.admin.tasks.get", REQUEST_MOCK_INVALID)
|
||||
def test_version_error(self):
|
||||
"""Test Update checker with invalid response"""
|
||||
update_latest_version.delay().get()
|
||||
self.assertEqual(cache.get(VERSION_CACHE_KEY), "0.0.0")
|
||||
self.assertFalse(
|
||||
Event.objects.filter(
|
||||
action=EventAction.UPDATE_AVAILABLE, context__new_version="0.0.0"
|
||||
).exists()
|
||||
)
|
@ -22,6 +22,7 @@ from authentik.admin.views import (
|
||||
tokens,
|
||||
users,
|
||||
)
|
||||
from authentik.providers.saml.views import MetadataImportView
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
@ -35,9 +36,6 @@ urlpatterns = [
|
||||
name="overview-clear-policy-cache",
|
||||
),
|
||||
# Applications
|
||||
path(
|
||||
"applications/", applications.ApplicationListView.as_view(), name="applications"
|
||||
),
|
||||
path(
|
||||
"applications/create/",
|
||||
applications.ApplicationCreateView.as_view(),
|
||||
@ -119,6 +117,11 @@ urlpatterns = [
|
||||
providers.ProviderCreateView.as_view(),
|
||||
name="provider-create",
|
||||
),
|
||||
path(
|
||||
"providers/create/saml/from-metadata/",
|
||||
MetadataImportView.as_view(),
|
||||
name="provider-saml-from-metadata",
|
||||
),
|
||||
path(
|
||||
"providers/<int:pk>/update/",
|
||||
providers.ProviderUpdateView.as_view(),
|
||||
|
@ -6,44 +6,15 @@ from django.contrib.auth.mixins import (
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import ListView, UpdateView
|
||||
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
||||
from django.views.generic import UpdateView
|
||||
from guardian.mixins import PermissionRequiredMixin
|
||||
|
||||
from authentik.admin.views.utils import (
|
||||
BackSuccessUrlMixin,
|
||||
DeleteMessageView,
|
||||
SearchListMixin,
|
||||
UserPaginateListMixin,
|
||||
)
|
||||
from authentik.admin.views.utils import BackSuccessUrlMixin, DeleteMessageView
|
||||
from authentik.core.forms.applications import ApplicationForm
|
||||
from authentik.core.models import Application
|
||||
from authentik.lib.views import CreateAssignPermView
|
||||
|
||||
|
||||
class ApplicationListView(
|
||||
LoginRequiredMixin,
|
||||
PermissionListMixin,
|
||||
UserPaginateListMixin,
|
||||
SearchListMixin,
|
||||
ListView,
|
||||
):
|
||||
"""Show list of all applications"""
|
||||
|
||||
model = Application
|
||||
permission_required = "authentik_core.view_application"
|
||||
ordering = "name"
|
||||
template_name = "administration/application/list.html"
|
||||
|
||||
search_fields = [
|
||||
"name",
|
||||
"slug",
|
||||
"meta_launch_url",
|
||||
"meta_icon_url",
|
||||
"meta_description",
|
||||
"meta_publisher",
|
||||
]
|
||||
|
||||
|
||||
class ApplicationCreateView(
|
||||
SuccessMessageMixin,
|
||||
BackSuccessUrlMixin,
|
||||
|
@ -19,7 +19,6 @@ from authentik.admin.views.utils import (
|
||||
from authentik.lib.views import CreateAssignPermView
|
||||
from authentik.stages.invitation.forms import InvitationForm
|
||||
from authentik.stages.invitation.models import Invitation
|
||||
from authentik.stages.invitation.signals import invitation_created
|
||||
|
||||
|
||||
class InvitationListView(
|
||||
@ -59,7 +58,6 @@ class InvitationCreateView(
|
||||
obj = form.save(commit=False)
|
||||
obj.created_by = self.request.user
|
||||
obj.save()
|
||||
invitation_created.send(sender=self, request=self.request, invitation=obj)
|
||||
return HttpResponseRedirect(self.success_url)
|
||||
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
"""core Configs API"""
|
||||
from django.db.models import Model
|
||||
from drf_yasg2.utils import swagger_auto_schema
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.request import Request
|
||||
@ -19,10 +20,10 @@ class ConfigSerializer(Serializer):
|
||||
error_reporting_environment = ReadOnlyField()
|
||||
error_reporting_send_pii = ReadOnlyField()
|
||||
|
||||
def create(self, request: Request) -> Response:
|
||||
def create(self, validated_data: dict) -> Model:
|
||||
raise NotImplementedError
|
||||
|
||||
def update(self, request: Request) -> Response:
|
||||
def update(self, instance: Model, validated_data: dict) -> Model:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
"""core messages API"""
|
||||
from django.contrib.messages import get_messages
|
||||
from django.db.models import Model
|
||||
from drf_yasg2.utils import swagger_auto_schema
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.request import Request
|
||||
@ -17,10 +18,10 @@ class MessageSerializer(Serializer):
|
||||
extra_tags = ReadOnlyField()
|
||||
level_tag = ReadOnlyField()
|
||||
|
||||
def create(self, request: Request) -> Response:
|
||||
def create(self, validated_data: dict) -> Model:
|
||||
raise NotImplementedError
|
||||
|
||||
def update(self, request: Request) -> Response:
|
||||
def update(self, instance: Model, validated_data: dict) -> Model:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
|
@ -11,7 +11,6 @@ from authentik.admin.api.version import VersionViewSet
|
||||
from authentik.admin.api.workers import WorkerViewSet
|
||||
from authentik.api.v2.config import ConfigsViewSet
|
||||
from authentik.api.v2.messages import MessagesViewSet
|
||||
from authentik.audit.api import EventViewSet
|
||||
from authentik.core.api.applications import ApplicationViewSet
|
||||
from authentik.core.api.groups import GroupViewSet
|
||||
from authentik.core.api.propertymappings import PropertyMappingViewSet
|
||||
@ -20,6 +19,7 @@ from authentik.core.api.sources import SourceViewSet
|
||||
from authentik.core.api.tokens import TokenViewSet
|
||||
from authentik.core.api.users import UserViewSet
|
||||
from authentik.crypto.api import CertificateKeyPairViewSet
|
||||
from authentik.events.api import EventViewSet
|
||||
from authentik.flows.api import (
|
||||
FlowCacheViewSet,
|
||||
FlowStageBindingViewSet,
|
||||
@ -96,7 +96,7 @@ router.register("flows/bindings", FlowStageBindingViewSet)
|
||||
|
||||
router.register("crypto/certificatekeypairs", CertificateKeyPairViewSet)
|
||||
|
||||
router.register("audit/events", EventViewSet)
|
||||
router.register("events/events", EventViewSet)
|
||||
|
||||
router.register("sources/all", SourceViewSet)
|
||||
router.register("sources/ldap", LDAPSourceViewSet)
|
||||
@ -148,7 +148,9 @@ info = openapi.Info(
|
||||
title="authentik API",
|
||||
default_version="v2",
|
||||
contact=openapi.Contact(email="hello@beryju.org"),
|
||||
license=openapi.License(name="MIT License"),
|
||||
license=openapi.License(
|
||||
name="GNU GPLv3", url="https://github.com/BeryJu/authentik/blob/master/LICENSE"
|
||||
),
|
||||
)
|
||||
SchemaView = get_schema_view(
|
||||
info,
|
||||
|
@ -1,16 +0,0 @@
|
||||
"""authentik audit app"""
|
||||
from importlib import import_module
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AuthentikAuditConfig(AppConfig):
|
||||
"""authentik audit app"""
|
||||
|
||||
name = "authentik.audit"
|
||||
label = "authentik_audit"
|
||||
verbose_name = "authentik Audit"
|
||||
mountpoint = "audit/"
|
||||
|
||||
def ready(self):
|
||||
import_module("authentik.audit.signals")
|
@ -1,9 +0,0 @@
|
||||
"""authentik audit urls"""
|
||||
from django.urls import path
|
||||
|
||||
from authentik.audit.views import EventListView
|
||||
|
||||
urlpatterns = [
|
||||
# Audit Log
|
||||
path("audit/", EventListView.as_view(), name="log"),
|
||||
]
|
@ -12,8 +12,9 @@ from rest_framework.viewsets import ModelViewSet
|
||||
from rest_framework_guardian.filters import ObjectPermissionsFilter
|
||||
|
||||
from authentik.admin.api.metrics import get_events_per_1h
|
||||
from authentik.audit.models import EventAction
|
||||
from authentik.core.api.providers import ProviderSerializer
|
||||
from authentik.core.models import Application
|
||||
from authentik.events.models import EventAction
|
||||
from authentik.policies.engine import PolicyEngine
|
||||
|
||||
|
||||
@ -21,6 +22,7 @@ class ApplicationSerializer(ModelSerializer):
|
||||
"""Application Serializer"""
|
||||
|
||||
launch_url = SerializerMethodField()
|
||||
provider = ProviderSerializer(source="get_provider", required=False)
|
||||
|
||||
def get_launch_url(self, instance: Application) -> str:
|
||||
"""Get generated launch URL"""
|
||||
@ -48,7 +50,15 @@ class ApplicationViewSet(ModelViewSet):
|
||||
|
||||
queryset = Application.objects.all()
|
||||
serializer_class = ApplicationSerializer
|
||||
search_fields = [
|
||||
"name",
|
||||
"slug",
|
||||
"meta_launch_url",
|
||||
"meta_description",
|
||||
"meta_publisher",
|
||||
]
|
||||
lookup_field = "slug"
|
||||
ordering = ["name"]
|
||||
|
||||
def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet:
|
||||
"""Custom filter_queryset method which ignores guardian, but still supports sorting"""
|
||||
@ -63,7 +73,7 @@ class ApplicationViewSet(ModelViewSet):
|
||||
queryset = self._filter_queryset_for_list(self.get_queryset())
|
||||
self.paginate_queryset(queryset)
|
||||
allowed_applications = []
|
||||
for application in queryset.order_by("name"):
|
||||
for application in queryset:
|
||||
engine = PolicyEngine(application, self.request.user, self.request)
|
||||
engine.build()
|
||||
if engine.passing:
|
||||
@ -78,7 +88,7 @@ class ApplicationViewSet(ModelViewSet):
|
||||
get_objects_for_user(request.user, "authentik_core.view_application"),
|
||||
slug=slug,
|
||||
)
|
||||
if not request.user.has_perm("authentik_audit.view_event"):
|
||||
if not request.user.has_perm("authentik_events.view_event"):
|
||||
raise Http404
|
||||
return Response(
|
||||
get_events_per_1h(
|
||||
|
@ -2,15 +2,16 @@
|
||||
from rest_framework.serializers import ModelSerializer, SerializerMethodField
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.core.api.utils import MetaNameSerializer
|
||||
from authentik.core.models import Provider
|
||||
|
||||
|
||||
class ProviderSerializer(ModelSerializer):
|
||||
class ProviderSerializer(ModelSerializer, MetaNameSerializer):
|
||||
"""Provider Serializer"""
|
||||
|
||||
__type__ = SerializerMethodField(method_name="get_type")
|
||||
object_type = SerializerMethodField()
|
||||
|
||||
def get_type(self, obj):
|
||||
def get_object_type(self, obj):
|
||||
"""Get object type so that we know which API Endpoint to use to get the full object"""
|
||||
return obj._meta.object_name.lower().replace("provider", "")
|
||||
|
||||
@ -29,7 +30,9 @@ class ProviderSerializer(ModelSerializer):
|
||||
"application",
|
||||
"authorization_flow",
|
||||
"property_mappings",
|
||||
"__type__",
|
||||
"object_type",
|
||||
"verbose_name",
|
||||
"verbose_name_plural",
|
||||
]
|
||||
|
||||
|
||||
|
@ -3,10 +3,11 @@ from rest_framework.serializers import ModelSerializer, SerializerMethodField
|
||||
from rest_framework.viewsets import ReadOnlyModelViewSet
|
||||
|
||||
from authentik.admin.forms.source import SOURCE_SERIALIZER_FIELDS
|
||||
from authentik.core.api.utils import MetaNameSerializer
|
||||
from authentik.core.models import Source
|
||||
|
||||
|
||||
class SourceSerializer(ModelSerializer):
|
||||
class SourceSerializer(ModelSerializer, MetaNameSerializer):
|
||||
"""Source Serializer"""
|
||||
|
||||
__type__ = SerializerMethodField(method_name="get_type")
|
||||
|
@ -6,8 +6,8 @@ from rest_framework.response import Response
|
||||
from rest_framework.serializers import ModelSerializer
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.audit.models import Event, EventAction
|
||||
from authentik.core.models import Token
|
||||
from authentik.events.models import Event, EventAction
|
||||
|
||||
|
||||
class TokenSerializer(ModelSerializer):
|
||||
|
@ -1,5 +1,6 @@
|
||||
"""User API Views"""
|
||||
from drf_yasg2.utils import swagger_auto_schema
|
||||
from guardian.utils import get_anonymous_user
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
@ -36,6 +37,9 @@ class UserViewSet(ModelViewSet):
|
||||
queryset = User.objects.all()
|
||||
serializer_class = UserSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
return User.objects.all().exclude(pk=get_anonymous_user().pk)
|
||||
|
||||
@swagger_auto_schema(responses={200: UserSerializer(many=False)})
|
||||
@action(detail=False)
|
||||
# pylint: disable=invalid-name
|
||||
|
24
authentik/core/api/utils.py
Normal file
24
authentik/core/api/utils.py
Normal file
@ -0,0 +1,24 @@
|
||||
"""API Utilities"""
|
||||
from django.db.models import Model
|
||||
from rest_framework.serializers import Serializer, SerializerMethodField
|
||||
|
||||
|
||||
class MetaNameSerializer(Serializer):
|
||||
"""Add verbose names to response"""
|
||||
|
||||
verbose_name = SerializerMethodField()
|
||||
verbose_name_plural = SerializerMethodField()
|
||||
|
||||
def create(self, validated_data: dict) -> Model:
|
||||
raise NotImplementedError
|
||||
|
||||
def update(self, instance: Model, validated_data: dict) -> Model:
|
||||
raise NotImplementedError
|
||||
|
||||
def get_verbose_name(self, obj: Model) -> str:
|
||||
"""Return object's verbose_name"""
|
||||
return obj._meta.verbose_name
|
||||
|
||||
def get_verbose_name_plural(self, obj: Model) -> str:
|
||||
"""Return object's plural verbose_name"""
|
||||
return obj._meta.verbose_name_plural
|
@ -1,9 +1,11 @@
|
||||
"""Property Mapping Evaluator"""
|
||||
from traceback import format_tb
|
||||
from typing import Optional
|
||||
|
||||
from django.http import HttpRequest
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.expression.evaluator import BaseEvaluator
|
||||
|
||||
|
||||
@ -19,3 +21,18 @@ class PropertyMappingEvaluator(BaseEvaluator):
|
||||
if request:
|
||||
self._context["request"] = request
|
||||
self._context.update(**kwargs)
|
||||
|
||||
def handle_error(self, exc: Exception, expression_source: str):
|
||||
"""Exception Handler"""
|
||||
error_string = "\n".join(format_tb(exc.__traceback__) + [str(exc)])
|
||||
event = Event.new(
|
||||
EventAction.PROPERTY_MAPPING_EXCEPTION,
|
||||
expression=expression_source,
|
||||
error=error_string,
|
||||
)
|
||||
if "user" in self._context:
|
||||
event.set_user(self._context["user"])
|
||||
if "request" in self._context:
|
||||
event.from_http(self._context["request"])
|
||||
return
|
||||
event.save()
|
||||
|
@ -31,7 +31,7 @@
|
||||
<p class="pf-c-form__helper-text">{{ field.help_text }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% elif field.field.widget|fieldtype == 'Select' %}
|
||||
{% elif field.field.widget|fieldtype == 'Select' or field.field.widget|fieldtype == "SelectMultiple" %}
|
||||
<div class="pf-c-form__group-label">
|
||||
<label class="pf-c-form__label" for="{{ field.name }}-{{ forloop.counter0 }}">
|
||||
<span class="pf-c-form__label-text">{{ field.label }}</span>
|
||||
@ -46,6 +46,9 @@
|
||||
{% if field.help_text %}
|
||||
<p class="pf-c-form__helper-text">{{ field.help_text|safe }}</p>
|
||||
{% endif %}
|
||||
{% if field.field.widget|fieldtype == 'SelectMultiple' %}
|
||||
<p class="pf-c-form__helper-text">{% trans 'Hold control/command to select multiple items.' %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% elif field.field.widget|fieldtype == 'CheckboxInput' %}
|
||||
|
56
authentik/core/tests/test_property_mapping.py
Normal file
56
authentik/core/tests/test_property_mapping.py
Normal file
@ -0,0 +1,56 @@
|
||||
"""authentik core property mapping tests"""
|
||||
from django.test import RequestFactory, TestCase
|
||||
from guardian.shortcuts import get_anonymous_user
|
||||
|
||||
from authentik.core.exceptions import PropertyMappingExpressionException
|
||||
from authentik.core.models import PropertyMapping
|
||||
from authentik.events.models import Event, EventAction
|
||||
|
||||
|
||||
class TestPropertyMappings(TestCase):
|
||||
"""authentik core property mapping tests"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.factory = RequestFactory()
|
||||
|
||||
def test_expression(self):
|
||||
"""Test expression"""
|
||||
mapping = PropertyMapping.objects.create(
|
||||
name="test", expression="return 'test'"
|
||||
)
|
||||
self.assertEqual(mapping.evaluate(None, None), "test")
|
||||
|
||||
def test_expression_syntax(self):
|
||||
"""Test expression syntax error"""
|
||||
mapping = PropertyMapping.objects.create(name="test", expression="-")
|
||||
with self.assertRaises(PropertyMappingExpressionException):
|
||||
mapping.evaluate(None, None)
|
||||
|
||||
def test_expression_error_general(self):
|
||||
"""Test expression error"""
|
||||
expr = "return aaa"
|
||||
mapping = PropertyMapping.objects.create(name="test", expression=expr)
|
||||
with self.assertRaises(NameError):
|
||||
mapping.evaluate(None, None)
|
||||
events = Event.objects.filter(
|
||||
action=EventAction.PROPERTY_MAPPING_EXCEPTION, context__expression=expr
|
||||
)
|
||||
self.assertTrue(events.exists())
|
||||
self.assertEqual(len(events), 1)
|
||||
|
||||
def test_expression_error_extended(self):
|
||||
"""Test expression error (with user and http request"""
|
||||
expr = "return aaa"
|
||||
request = self.factory.get("/")
|
||||
mapping = PropertyMapping.objects.create(name="test", expression=expr)
|
||||
with self.assertRaises(NameError):
|
||||
mapping.evaluate(get_anonymous_user(), request)
|
||||
events = Event.objects.filter(
|
||||
action=EventAction.PROPERTY_MAPPING_EXCEPTION, context__expression=expr
|
||||
)
|
||||
self.assertTrue(events.exists())
|
||||
self.assertEqual(len(events), 1)
|
||||
event = events.first()
|
||||
self.assertEqual(event.user["username"], "AnonymousUser")
|
||||
self.assertEqual(event.client_ip, "127.0.0.1")
|
@ -5,12 +5,12 @@ from django.shortcuts import get_object_or_404, redirect
|
||||
from django.views import View
|
||||
from structlog import get_logger
|
||||
|
||||
from authentik.audit.models import Event, EventAction
|
||||
from authentik.core.middleware import (
|
||||
SESSION_IMPERSONATE_ORIGINAL_USER,
|
||||
SESSION_IMPERSONATE_USER,
|
||||
)
|
||||
from authentik.core.models import User
|
||||
from authentik.events.models import Event, EventAction
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
@ -94,11 +94,6 @@ class TokenCreateView(
|
||||
success_url = reverse_lazy("authentik_core:user-tokens")
|
||||
success_message = _("Successfully created Token")
|
||||
|
||||
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
kwargs["container_template"] = "user/base.html"
|
||||
return kwargs
|
||||
|
||||
def form_valid(self, form: UserTokenForm) -> HttpResponse:
|
||||
form.instance.user = self.request.user
|
||||
form.instance.intent = TokenIntents.INTENT_API
|
||||
@ -112,21 +107,20 @@ class TokenUpdateView(
|
||||
|
||||
model = Token
|
||||
form_class = UserTokenForm
|
||||
permission_required = "authentik_core.update_token"
|
||||
permission_required = "authentik_core.change_token"
|
||||
template_name = "generic/update.html"
|
||||
success_url = reverse_lazy("authentik_core:user-tokens")
|
||||
success_message = _("Successfully updated Token")
|
||||
|
||||
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
kwargs["container_template"] = "user/base.html"
|
||||
return kwargs
|
||||
|
||||
def get_object(self) -> Token:
|
||||
identifier = self.kwargs.get("identifier")
|
||||
return get_objects_for_user(
|
||||
self.request.user, "authentik_core.update_token", self.model
|
||||
).filter(intent=TokenIntents.INTENT_API, identifier=identifier)
|
||||
return (
|
||||
get_objects_for_user(
|
||||
self.request.user, self.permission_required, self.model
|
||||
)
|
||||
.filter(intent=TokenIntents.INTENT_API, identifier=identifier)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
class TokenDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView):
|
||||
@ -138,7 +132,12 @@ class TokenDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessage
|
||||
success_url = reverse_lazy("authentik_core:user-tokens")
|
||||
success_message = _("Successfully deleted Token")
|
||||
|
||||
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
kwargs["container_template"] = "user/base.html"
|
||||
return kwargs
|
||||
def get_object(self) -> Token:
|
||||
identifier = self.kwargs.get("identifier")
|
||||
return (
|
||||
get_objects_for_user(
|
||||
self.request.user, self.permission_required, self.model
|
||||
)
|
||||
.filter(intent=TokenIntents.INTENT_API, identifier=identifier)
|
||||
.first()
|
||||
)
|
||||
|
@ -1,4 +1,4 @@
|
||||
"""Audit API Views"""
|
||||
"""Events API Views"""
|
||||
from django.db.models.aggregates import Count
|
||||
from django.db.models.fields.json import KeyTextTransform
|
||||
from drf_yasg2.utils import swagger_auto_schema
|
||||
@ -9,7 +9,7 @@ from rest_framework.response import Response
|
||||
from rest_framework.serializers import ModelSerializer, Serializer
|
||||
from rest_framework.viewsets import ReadOnlyModelViewSet
|
||||
|
||||
from authentik.audit.models import Event, EventAction
|
||||
from authentik.events.models import Event, EventAction
|
||||
|
||||
|
||||
class EventSerializer(ModelSerializer):
|
16
authentik/events/apps.py
Normal file
16
authentik/events/apps.py
Normal file
@ -0,0 +1,16 @@
|
||||
"""authentik events app"""
|
||||
from importlib import import_module
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AuthentikEventsConfig(AppConfig):
|
||||
"""authentik events app"""
|
||||
|
||||
name = "authentik.events"
|
||||
label = "authentik_events"
|
||||
verbose_name = "authentik Events"
|
||||
mountpoint = "events/"
|
||||
|
||||
def ready(self):
|
||||
import_module("authentik.events.signals")
|
@ -1,4 +1,4 @@
|
||||
"""Audit middleware"""
|
||||
"""Events middleware"""
|
||||
from functools import partial
|
||||
from typing import Callable
|
||||
|
||||
@ -7,9 +7,10 @@ from django.db.models import Model
|
||||
from django.db.models.signals import post_save, pre_delete
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
|
||||
from authentik.audit.models import Event, EventAction, model_to_dict
|
||||
from authentik.audit.signals import EventNewThread
|
||||
from authentik.core.middleware import LOCAL
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.events.signals import EventNewThread
|
||||
from authentik.events.utils import model_to_dict
|
||||
|
||||
|
||||
class AuditMiddleware:
|
@ -63,8 +63,8 @@ class Migration(migrations.Migration):
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Audit Event",
|
||||
"verbose_name_plural": "Audit Events",
|
||||
"verbose_name": "Event",
|
||||
"verbose_name_plural": "Events",
|
||||
},
|
||||
),
|
||||
]
|
@ -6,7 +6,7 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_audit", "0001_initial"),
|
||||
("authentik_events", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
@ -3,11 +3,11 @@ from django.apps.registry import Apps
|
||||
from django.db import migrations, models
|
||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||
|
||||
import authentik.audit.models
|
||||
import authentik.events.models
|
||||
|
||||
|
||||
def convert_user_to_json(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
Event = apps.get_model("authentik_audit", "Event")
|
||||
Event = apps.get_model("authentik_events", "Event")
|
||||
|
||||
db_alias = schema_editor.connection.alias
|
||||
for event in Event.objects.all():
|
||||
@ -15,7 +15,7 @@ def convert_user_to_json(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
# Because event objects cannot be updated, we have to re-create them
|
||||
event.pk = None
|
||||
event.user_json = (
|
||||
authentik.audit.models.get_user(event.user) if event.user else {}
|
||||
authentik.events.models.get_user(event.user) if event.user else {}
|
||||
)
|
||||
event._state.adding = True
|
||||
event.save()
|
||||
@ -24,7 +24,7 @@ def convert_user_to_json(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_audit", "0002_auto_20200918_2116"),
|
||||
("authentik_events", "0002_auto_20200918_2116"),
|
||||
]
|
||||
|
||||
operations = [
|
@ -6,7 +6,7 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_audit", "0003_auto_20200917_1155"),
|
||||
("authentik_events", "0003_auto_20200917_1155"),
|
||||
]
|
||||
|
||||
operations = [
|
@ -6,7 +6,7 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_audit", "0004_auto_20200921_1829"),
|
||||
("authentik_events", "0004_auto_20200921_1829"),
|
||||
]
|
||||
|
||||
operations = [
|
@ -6,7 +6,7 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_audit", "0005_auto_20201005_2139"),
|
||||
("authentik_events", "0005_auto_20201005_2139"),
|
||||
]
|
||||
|
||||
operations = [
|
41
authentik/events/migrations/0007_auto_20201215_0939.py
Normal file
41
authentik/events/migrations/0007_auto_20201215_0939.py
Normal file
@ -0,0 +1,41 @@
|
||||
# Generated by Django 3.1.4 on 2020-12-15 09:39
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_events", "0006_auto_20201017_2024"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="event",
|
||||
name="action",
|
||||
field=models.TextField(
|
||||
choices=[
|
||||
("login", "Login"),
|
||||
("login_failed", "Login Failed"),
|
||||
("logout", "Logout"),
|
||||
("user_write", "User Write"),
|
||||
("suspicious_request", "Suspicious Request"),
|
||||
("password_set", "Password Set"),
|
||||
("token_view", "Token View"),
|
||||
("invitation_created", "Invite Created"),
|
||||
("invitation_used", "Invite Used"),
|
||||
("authorize_application", "Authorize Application"),
|
||||
("source_linked", "Source Linked"),
|
||||
("impersonation_started", "Impersonation Started"),
|
||||
("impersonation_ended", "Impersonation Ended"),
|
||||
("policy_execution", "Policy Execution"),
|
||||
("policy_exception", "Policy Exception"),
|
||||
("property_mapping_exception", "Property Mapping Exception"),
|
||||
("model_created", "Model Created"),
|
||||
("model_updated", "Model Updated"),
|
||||
("model_deleted", "Model Deleted"),
|
||||
("custom_", "Custom Prefix"),
|
||||
]
|
||||
),
|
||||
),
|
||||
]
|
42
authentik/events/migrations/0008_auto_20201220_1651.py
Normal file
42
authentik/events/migrations/0008_auto_20201220_1651.py
Normal file
@ -0,0 +1,42 @@
|
||||
# Generated by Django 3.1.4 on 2020-12-20 16:51
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_events", "0007_auto_20201215_0939"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="event",
|
||||
name="action",
|
||||
field=models.TextField(
|
||||
choices=[
|
||||
("login", "Login"),
|
||||
("login_failed", "Login Failed"),
|
||||
("logout", "Logout"),
|
||||
("user_write", "User Write"),
|
||||
("suspicious_request", "Suspicious Request"),
|
||||
("password_set", "Password Set"),
|
||||
("token_view", "Token View"),
|
||||
("invitation_created", "Invite Created"),
|
||||
("invitation_used", "Invite Used"),
|
||||
("authorize_application", "Authorize Application"),
|
||||
("source_linked", "Source Linked"),
|
||||
("impersonation_started", "Impersonation Started"),
|
||||
("impersonation_ended", "Impersonation Ended"),
|
||||
("policy_execution", "Policy Execution"),
|
||||
("policy_exception", "Policy Exception"),
|
||||
("property_mapping_exception", "Property Mapping Exception"),
|
||||
("model_created", "Model Created"),
|
||||
("model_updated", "Model Updated"),
|
||||
("model_deleted", "Model Deleted"),
|
||||
("update_available", "Update Available"),
|
||||
("custom_", "Custom Prefix"),
|
||||
]
|
||||
),
|
||||
),
|
||||
]
|
42
authentik/events/migrations/0009_auto_20201227_1210.py
Normal file
42
authentik/events/migrations/0009_auto_20201227_1210.py
Normal file
@ -0,0 +1,42 @@
|
||||
# Generated by Django 3.1.4 on 2020-12-27 12:10
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_events", "0008_auto_20201220_1651"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="event",
|
||||
name="action",
|
||||
field=models.TextField(
|
||||
choices=[
|
||||
("login", "Login"),
|
||||
("login_failed", "Login Failed"),
|
||||
("logout", "Logout"),
|
||||
("user_write", "User Write"),
|
||||
("suspicious_request", "Suspicious Request"),
|
||||
("password_set", "Password Set"),
|
||||
("token_view", "Token View"),
|
||||
("invitation_used", "Invite Used"),
|
||||
("authorize_application", "Authorize Application"),
|
||||
("source_linked", "Source Linked"),
|
||||
("impersonation_started", "Impersonation Started"),
|
||||
("impersonation_ended", "Impersonation Ended"),
|
||||
("policy_execution", "Policy Execution"),
|
||||
("policy_exception", "Policy Exception"),
|
||||
("property_mapping_exception", "Property Mapping Exception"),
|
||||
("configuration_error", "Configuration Error"),
|
||||
("model_created", "Model Created"),
|
||||
("model_updated", "Model Updated"),
|
||||
("model_deleted", "Model Deleted"),
|
||||
("update_available", "Update Available"),
|
||||
("custom_", "Custom Prefix"),
|
||||
]
|
||||
),
|
||||
),
|
||||
]
|
@ -1,17 +1,14 @@
|
||||
"""authentik audit models"""
|
||||
"""authentik events models"""
|
||||
|
||||
from inspect import getmodule, stack
|
||||
from typing import Any, Dict, Optional, Union
|
||||
from uuid import UUID, uuid4
|
||||
from typing import Optional, Union
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.db.models.base import Model
|
||||
from django.http import HttpRequest
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.debug import SafeExceptionReporterFilter
|
||||
from guardian.utils import get_anonymous_user
|
||||
from structlog import get_logger
|
||||
|
||||
from authentik.core.middleware import (
|
||||
@ -19,78 +16,14 @@ from authentik.core.middleware import (
|
||||
SESSION_IMPERSONATE_USER,
|
||||
)
|
||||
from authentik.core.models import User
|
||||
from authentik.events.utils import cleanse_dict, get_user, sanitize_dict
|
||||
from authentik.lib.utils.http import get_client_ip
|
||||
|
||||
LOGGER = get_logger("authentik.audit")
|
||||
|
||||
|
||||
def cleanse_dict(source: Dict[Any, Any]) -> Dict[Any, Any]:
|
||||
"""Cleanse a dictionary, recursively"""
|
||||
final_dict = {}
|
||||
for key, value in source.items():
|
||||
try:
|
||||
if SafeExceptionReporterFilter.hidden_settings.search(key):
|
||||
final_dict[key] = SafeExceptionReporterFilter.cleansed_substitute
|
||||
else:
|
||||
final_dict[key] = value
|
||||
except TypeError:
|
||||
final_dict[key] = value
|
||||
if isinstance(value, dict):
|
||||
final_dict[key] = cleanse_dict(value)
|
||||
return final_dict
|
||||
|
||||
|
||||
def model_to_dict(model: Model) -> Dict[str, Any]:
|
||||
"""Convert model to dict"""
|
||||
name = str(model)
|
||||
if hasattr(model, "name"):
|
||||
name = model.name
|
||||
return {
|
||||
"app": model._meta.app_label,
|
||||
"model_name": model._meta.model_name,
|
||||
"pk": model.pk,
|
||||
"name": name,
|
||||
}
|
||||
|
||||
|
||||
def get_user(user: User, original_user: Optional[User] = None) -> Dict[str, Any]:
|
||||
"""Convert user object to dictionary, optionally including the original user"""
|
||||
if isinstance(user, AnonymousUser):
|
||||
user = get_anonymous_user()
|
||||
user_data = {
|
||||
"username": user.username,
|
||||
"pk": user.pk,
|
||||
"email": user.email,
|
||||
}
|
||||
if original_user:
|
||||
original_data = get_user(original_user)
|
||||
original_data["on_behalf_of"] = user_data
|
||||
return original_data
|
||||
return user_data
|
||||
|
||||
|
||||
def sanitize_dict(source: Dict[Any, Any]) -> Dict[Any, Any]:
|
||||
"""clean source of all Models that would interfere with the JSONField.
|
||||
Models are replaced with a dictionary of {
|
||||
app: str,
|
||||
name: str,
|
||||
pk: Any
|
||||
}"""
|
||||
final_dict = {}
|
||||
for key, value in source.items():
|
||||
if isinstance(value, dict):
|
||||
final_dict[key] = sanitize_dict(value)
|
||||
elif isinstance(value, models.Model):
|
||||
final_dict[key] = sanitize_dict(model_to_dict(value))
|
||||
elif isinstance(value, UUID):
|
||||
final_dict[key] = value.hex
|
||||
else:
|
||||
final_dict[key] = value
|
||||
return final_dict
|
||||
LOGGER = get_logger("authentik.events")
|
||||
|
||||
|
||||
class EventAction(models.TextChoices):
|
||||
"""All possible actions to save into the audit log"""
|
||||
"""All possible actions to save into the events log"""
|
||||
|
||||
LOGIN = "login"
|
||||
LOGIN_FAILED = "login_failed"
|
||||
@ -102,7 +35,6 @@ class EventAction(models.TextChoices):
|
||||
|
||||
TOKEN_VIEW = "token_view" # nosec
|
||||
|
||||
INVITE_CREATED = "invitation_created"
|
||||
INVITE_USED = "invitation_used"
|
||||
|
||||
AUTHORIZE_APPLICATION = "authorize_application"
|
||||
@ -111,15 +43,23 @@ class EventAction(models.TextChoices):
|
||||
IMPERSONATION_STARTED = "impersonation_started"
|
||||
IMPERSONATION_ENDED = "impersonation_ended"
|
||||
|
||||
POLICY_EXECUTION = "policy_execution"
|
||||
POLICY_EXCEPTION = "policy_exception"
|
||||
PROPERTY_MAPPING_EXCEPTION = "property_mapping_exception"
|
||||
|
||||
CONFIGURATION_ERROR = "configuration_error"
|
||||
|
||||
MODEL_CREATED = "model_created"
|
||||
MODEL_UPDATED = "model_updated"
|
||||
MODEL_DELETED = "model_deleted"
|
||||
|
||||
UPDATE_AVAILABLE = "update_available"
|
||||
|
||||
CUSTOM_PREFIX = "custom_"
|
||||
|
||||
|
||||
class Event(models.Model):
|
||||
"""An individual audit log event"""
|
||||
"""An individual Audit/Metrics/Notification/Error Event"""
|
||||
|
||||
event_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
||||
user = models.JSONField(default=dict)
|
||||
@ -151,6 +91,12 @@ class Event(models.Model):
|
||||
event = Event(action=action, app=app, context=cleaned_kwargs)
|
||||
return event
|
||||
|
||||
def set_user(self, user: User) -> "Event":
|
||||
"""Set `.user` based on user, ensuring the correct attributes are copied.
|
||||
This should only be used when self.from_http is *not* used."""
|
||||
self.user = get_user(user)
|
||||
return self
|
||||
|
||||
def from_http(
|
||||
self, request: HttpRequest, user: Optional[settings.AUTH_USER_MODEL] = None
|
||||
) -> "Event":
|
||||
@ -185,7 +131,7 @@ class Event(models.Model):
|
||||
"you may not edit an existing %s" % self._meta.model_name
|
||||
)
|
||||
LOGGER.debug(
|
||||
"Created Audit event",
|
||||
"Created Event",
|
||||
action=self.action,
|
||||
context=self.context,
|
||||
client_ip=self.client_ip,
|
||||
@ -195,5 +141,5 @@ class Event(models.Model):
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("Audit Event")
|
||||
verbose_name_plural = _("Audit Events")
|
||||
verbose_name = _("Event")
|
||||
verbose_name_plural = _("Events")
|
@ -1,4 +1,4 @@
|
||||
"""authentik audit signal listener"""
|
||||
"""authentik events signal listener"""
|
||||
from threading import Thread
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
@ -10,11 +10,11 @@ from django.contrib.auth.signals import (
|
||||
from django.dispatch import receiver
|
||||
from django.http import HttpRequest
|
||||
|
||||
from authentik.audit.models import Event, EventAction
|
||||
from authentik.core.models import User
|
||||
from authentik.core.signals import password_changed
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.stages.invitation.models import Invitation
|
||||
from authentik.stages.invitation.signals import invitation_created, invitation_used
|
||||
from authentik.stages.invitation.signals import invitation_used
|
||||
from authentik.stages.user_write.signals import user_write
|
||||
|
||||
|
||||
@ -79,16 +79,6 @@ def on_user_login_failed(
|
||||
thread.run()
|
||||
|
||||
|
||||
@receiver(invitation_created)
|
||||
# pylint: disable=unused-argument
|
||||
def on_invitation_created(sender, request: HttpRequest, invitation: Invitation, **_):
|
||||
"""Log Invitation creation"""
|
||||
thread = EventNewThread(
|
||||
EventAction.INVITE_CREATED, request, invitation_uuid=invitation.invite_uuid.hex
|
||||
)
|
||||
thread.run()
|
||||
|
||||
|
||||
@receiver(invitation_used)
|
||||
# pylint: disable=unused-argument
|
||||
def on_invitation_used(sender, request: HttpRequest, invitation: Invitation, **_):
|
@ -9,7 +9,7 @@
|
||||
<div class="pf-c-content">
|
||||
<h1>
|
||||
<i class="pf-icon pf-icon-catalog"></i>
|
||||
{% trans 'Audit Log' %}
|
||||
{% trans 'Event Log' %}
|
||||
</h1>
|
||||
</div>
|
||||
</section>
|
@ -1,15 +1,15 @@
|
||||
"""audit event tests"""
|
||||
"""events event tests"""
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import TestCase
|
||||
from guardian.shortcuts import get_anonymous_user
|
||||
|
||||
from authentik.audit.models import Event
|
||||
from authentik.events.models import Event
|
||||
from authentik.policies.dummy.models import DummyPolicy
|
||||
|
||||
|
||||
class TestAuditEvent(TestCase):
|
||||
"""Test Audit Event"""
|
||||
class TestEvents(TestCase):
|
||||
"""Test Event"""
|
||||
|
||||
def test_new_with_model(self):
|
||||
"""Create a new Event passing a model as kwarg"""
|
9
authentik/events/urls.py
Normal file
9
authentik/events/urls.py
Normal file
@ -0,0 +1,9 @@
|
||||
"""authentik events urls"""
|
||||
from django.urls import path
|
||||
|
||||
from authentik.events.views import EventListView
|
||||
|
||||
urlpatterns = [
|
||||
# Event Log
|
||||
path("log/", EventListView.as_view(), name="log"),
|
||||
]
|
86
authentik/events/utils.py
Normal file
86
authentik/events/utils.py
Normal file
@ -0,0 +1,86 @@
|
||||
"""event utilities"""
|
||||
import re
|
||||
from dataclasses import asdict, is_dataclass
|
||||
from typing import Any, Dict, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.db import models
|
||||
from django.db.models.base import Model
|
||||
from django.views.debug import SafeExceptionReporterFilter
|
||||
from guardian.utils import get_anonymous_user
|
||||
|
||||
from authentik.core.models import User
|
||||
|
||||
# Special keys which are *not* cleaned, even when the default filter
|
||||
# is matched
|
||||
ALLOWED_SPECIAL_KEYS = re.compile("passing", flags=re.I)
|
||||
|
||||
|
||||
def cleanse_dict(source: Dict[Any, Any]) -> Dict[Any, Any]:
|
||||
"""Cleanse a dictionary, recursively"""
|
||||
final_dict = {}
|
||||
for key, value in source.items():
|
||||
try:
|
||||
if SafeExceptionReporterFilter.hidden_settings.search(
|
||||
key
|
||||
) and not ALLOWED_SPECIAL_KEYS.search(key):
|
||||
final_dict[key] = SafeExceptionReporterFilter.cleansed_substitute
|
||||
else:
|
||||
final_dict[key] = value
|
||||
except TypeError: # pragma: no cover
|
||||
final_dict[key] = value
|
||||
if isinstance(value, dict):
|
||||
final_dict[key] = cleanse_dict(value)
|
||||
return final_dict
|
||||
|
||||
|
||||
def model_to_dict(model: Model) -> Dict[str, Any]:
|
||||
"""Convert model to dict"""
|
||||
name = str(model)
|
||||
if hasattr(model, "name"):
|
||||
name = model.name
|
||||
return {
|
||||
"app": model._meta.app_label,
|
||||
"model_name": model._meta.model_name,
|
||||
"pk": model.pk,
|
||||
"name": name,
|
||||
}
|
||||
|
||||
|
||||
def get_user(user: User, original_user: Optional[User] = None) -> Dict[str, Any]:
|
||||
"""Convert user object to dictionary, optionally including the original user"""
|
||||
if isinstance(user, AnonymousUser):
|
||||
user = get_anonymous_user()
|
||||
user_data = {
|
||||
"username": user.username,
|
||||
"pk": user.pk,
|
||||
"email": user.email,
|
||||
}
|
||||
if original_user:
|
||||
original_data = get_user(original_user)
|
||||
original_data["on_behalf_of"] = user_data
|
||||
return original_data
|
||||
return user_data
|
||||
|
||||
|
||||
def sanitize_dict(source: Dict[Any, Any]) -> Dict[Any, Any]:
|
||||
"""clean source of all Models that would interfere with the JSONField.
|
||||
Models are replaced with a dictionary of {
|
||||
app: str,
|
||||
name: str,
|
||||
pk: Any
|
||||
}"""
|
||||
final_dict = {}
|
||||
for key, value in source.items():
|
||||
if is_dataclass(value):
|
||||
value = asdict(value)
|
||||
if isinstance(value, dict):
|
||||
final_dict[key] = sanitize_dict(value)
|
||||
elif isinstance(value, models.Model):
|
||||
final_dict[key] = sanitize_dict(model_to_dict(value))
|
||||
elif isinstance(value, UUID):
|
||||
final_dict[key] = value.hex
|
||||
else:
|
||||
final_dict[key] = value
|
||||
return final_dict
|
@ -4,7 +4,7 @@ from django.views.generic import ListView
|
||||
from guardian.mixins import PermissionListMixin
|
||||
|
||||
from authentik.admin.views.utils import SearchListMixin, UserPaginateListMixin
|
||||
from authentik.audit.models import Event
|
||||
from authentik.events.models import Event
|
||||
|
||||
|
||||
class EventListView(
|
||||
@ -17,8 +17,8 @@ class EventListView(
|
||||
"""Show list of all invitations"""
|
||||
|
||||
model = Event
|
||||
template_name = "audit/list.html"
|
||||
permission_required = "authentik_audit.view_event"
|
||||
template_name = "events/list.html"
|
||||
permission_required = "authentik_events.view_event"
|
||||
ordering = "-created"
|
||||
|
||||
search_fields = [
|
@ -1,9 +1,17 @@
|
||||
"""Flow API Views"""
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.db.models import Model
|
||||
from django.shortcuts import get_object_or_404
|
||||
from drf_yasg2.utils import swagger_auto_schema
|
||||
from guardian.shortcuts import get_objects_for_user
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.mixins import ListModelMixin
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.serializers import (
|
||||
CharField,
|
||||
ModelSerializer,
|
||||
Serializer,
|
||||
SerializerMethodField,
|
||||
@ -40,6 +48,30 @@ class FlowSerializer(ModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class FlowDiagramSerializer(Serializer):
|
||||
"""response of the flow's /diagram/ action"""
|
||||
|
||||
diagram = CharField(read_only=True)
|
||||
|
||||
def create(self, validated_data: dict) -> Model:
|
||||
raise NotImplementedError
|
||||
|
||||
def update(self, instance: Model, validated_data: dict) -> Model:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiagramElement:
|
||||
"""Single element used in a diagram"""
|
||||
|
||||
identifier: str
|
||||
type: str
|
||||
rest: str
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.identifier}=>{self.type}: {self.rest}"
|
||||
|
||||
|
||||
class FlowViewSet(ModelViewSet):
|
||||
"""Flow Viewset"""
|
||||
|
||||
@ -47,6 +79,78 @@ class FlowViewSet(ModelViewSet):
|
||||
serializer_class = FlowSerializer
|
||||
lookup_field = "slug"
|
||||
|
||||
@swagger_auto_schema(responses={200: FlowDiagramSerializer()})
|
||||
@action(detail=True, methods=["get"])
|
||||
def diagram(self, request: Request, slug: str) -> Response:
|
||||
"""Return diagram for flow with slug `slug`, in the format used by flowchart.js"""
|
||||
flow = get_object_or_404(
|
||||
get_objects_for_user(request.user, "authentik_flows.view_flow").filter(
|
||||
slug=slug
|
||||
)
|
||||
)
|
||||
header = [
|
||||
DiagramElement("st", "start", "Start"),
|
||||
]
|
||||
body: list[DiagramElement] = []
|
||||
footer = []
|
||||
# First, collect all elements we need
|
||||
for s_index, stage_binding in enumerate(
|
||||
get_objects_for_user(request.user, "authentik_flows.view_flowstagebinding")
|
||||
.filter(target=flow)
|
||||
.order_by("order")
|
||||
):
|
||||
body.append(
|
||||
DiagramElement(
|
||||
f"stage_{s_index}",
|
||||
"operation",
|
||||
f"Stage\n{stage_binding.stage.name}",
|
||||
)
|
||||
)
|
||||
for p_index, policy_binding in enumerate(
|
||||
get_objects_for_user(
|
||||
request.user, "authentik_policies.view_policybinding"
|
||||
)
|
||||
.filter(target=stage_binding)
|
||||
.order_by("order")
|
||||
):
|
||||
body.append(
|
||||
DiagramElement(
|
||||
f"stage_{s_index}_policy_{p_index}",
|
||||
"condition",
|
||||
f"Policy\n{policy_binding.policy.name}",
|
||||
)
|
||||
)
|
||||
# If the 2nd last element is a policy, we need to have an item to point to
|
||||
# for a negative case
|
||||
body.append(
|
||||
DiagramElement("e", "end", "End|future"),
|
||||
)
|
||||
if len(body) == 1:
|
||||
footer.append("st(right)->e")
|
||||
else:
|
||||
# Actual diagram flow
|
||||
footer.append(f"st(right)->{body[0].identifier}")
|
||||
for index in range(len(body) - 1):
|
||||
element: DiagramElement = body[index]
|
||||
if element.type == "condition":
|
||||
# Policy passes, link policy yes to next stage
|
||||
footer.append(
|
||||
f"{element.identifier}(yes, right)->{body[index + 1].identifier}"
|
||||
)
|
||||
# Policy doesn't pass, go to stage after next stage
|
||||
no_element = body[index + 1]
|
||||
if no_element.type != "end":
|
||||
no_element = body[index + 2]
|
||||
footer.append(
|
||||
f"{element.identifier}(no, bottom)->{no_element.identifier}"
|
||||
)
|
||||
elif element.type == "operation":
|
||||
footer.append(
|
||||
f"{element.identifier}(bottom)->{body[index + 1].identifier}"
|
||||
)
|
||||
diagram = "\n".join([str(x) for x in header + body + footer])
|
||||
return Response({"diagram": diagram})
|
||||
|
||||
|
||||
class StageSerializer(ModelSerializer):
|
||||
"""Stage Serializer"""
|
||||
|
@ -8,8 +8,8 @@ from sentry_sdk.hub import Hub
|
||||
from sentry_sdk.tracing import Span
|
||||
from structlog import get_logger
|
||||
|
||||
from authentik.audit.models import cleanse_dict
|
||||
from authentik.core.models import User
|
||||
from authentik.events.models import cleanse_dict
|
||||
from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException
|
||||
from authentik.flows.markers import ReevaluateMarker, StageMarker
|
||||
from authentik.flows.models import Flow, FlowStageBinding, Stage
|
||||
|
92
authentik/flows/tests/test_api.py
Normal file
92
authentik/flows/tests/test_api.py
Normal file
@ -0,0 +1,92 @@
|
||||
"""API flow tests"""
|
||||
from django.shortcuts import reverse
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.flows.api import StageSerializer, StageViewSet
|
||||
from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding, Stage
|
||||
from authentik.policies.dummy.models import DummyPolicy
|
||||
from authentik.policies.models import PolicyBinding
|
||||
from authentik.stages.dummy.models import DummyStage
|
||||
|
||||
DIAGRAM_EXPECTED = """st=>start: Start
|
||||
stage_0=>operation: Stage
|
||||
dummy1
|
||||
stage_1=>operation: Stage
|
||||
dummy2
|
||||
stage_1_policy_0=>condition: Policy
|
||||
None
|
||||
e=>end: End|future
|
||||
st(right)->stage_0
|
||||
stage_0(bottom)->stage_1
|
||||
stage_1(bottom)->stage_1_policy_0
|
||||
stage_1_policy_0(yes, right)->e
|
||||
stage_1_policy_0(no, bottom)->e"""
|
||||
DIAGRAM_SHORT_EXPECTED = """st=>start: Start
|
||||
e=>end: End|future
|
||||
st(right)->e"""
|
||||
|
||||
|
||||
class TestFlowsAPI(APITestCase):
|
||||
"""API tests"""
|
||||
|
||||
def test_models(self):
|
||||
"""Test that ui_user_settings returns none"""
|
||||
self.assertIsNone(Stage().ui_user_settings)
|
||||
|
||||
def test_api_serializer(self):
|
||||
"""Test that stage serializer returns the correct type"""
|
||||
obj = DummyStage()
|
||||
self.assertEqual(StageSerializer().get_type(obj), "dummy")
|
||||
self.assertEqual(StageSerializer().get_verbose_name(obj), "Dummy Stage")
|
||||
|
||||
def test_api_viewset(self):
|
||||
"""Test that stage serializer returns the correct type"""
|
||||
dummy = DummyStage.objects.create()
|
||||
self.assertIn(dummy, StageViewSet().get_queryset())
|
||||
|
||||
def test_api_diagram(self):
|
||||
"""Test flow diagram."""
|
||||
user = User.objects.get(username="akadmin")
|
||||
self.client.force_login(user)
|
||||
|
||||
flow = Flow.objects.create(
|
||||
name="test-default-context",
|
||||
slug="test-default-context",
|
||||
designation=FlowDesignation.AUTHENTICATION,
|
||||
)
|
||||
false_policy = DummyPolicy.objects.create(result=False, wait_min=1, wait_max=2)
|
||||
|
||||
FlowStageBinding.objects.create(
|
||||
target=flow, stage=DummyStage.objects.create(name="dummy1"), order=0
|
||||
)
|
||||
binding2 = FlowStageBinding.objects.create(
|
||||
target=flow,
|
||||
stage=DummyStage.objects.create(name="dummy2"),
|
||||
order=1,
|
||||
re_evaluate_policies=True,
|
||||
)
|
||||
|
||||
PolicyBinding.objects.create(policy=false_policy, target=binding2, order=0)
|
||||
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:flow-diagram", kwargs={"slug": flow.slug})
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertJSONEqual(response.content, {"diagram": DIAGRAM_EXPECTED})
|
||||
|
||||
def test_api_diagram_no_stages(self):
|
||||
"""Test flow diagram with no stages."""
|
||||
user = User.objects.get(username="akadmin")
|
||||
self.client.force_login(user)
|
||||
|
||||
flow = Flow.objects.create(
|
||||
name="test-default-context",
|
||||
slug="test-default-context",
|
||||
designation=FlowDesignation.AUTHENTICATION,
|
||||
)
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:flow-diagram", kwargs={"slug": flow.slug})
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertJSONEqual(response.content, {"diagram": DIAGRAM_SHORT_EXPECTED})
|
@ -1,25 +0,0 @@
|
||||
"""miscellaneous flow tests"""
|
||||
from django.test import TestCase
|
||||
|
||||
from authentik.flows.api import StageSerializer, StageViewSet
|
||||
from authentik.flows.models import Stage
|
||||
from authentik.stages.dummy.models import DummyStage
|
||||
|
||||
|
||||
class TestFlowsMisc(TestCase):
|
||||
"""miscellaneous tests"""
|
||||
|
||||
def test_models(self):
|
||||
"""Test that ui_user_settings returns none"""
|
||||
self.assertIsNone(Stage().ui_user_settings)
|
||||
|
||||
def test_api_serializer(self):
|
||||
"""Test that stage serializer returns the correct type"""
|
||||
obj = DummyStage()
|
||||
self.assertEqual(StageSerializer().get_type(obj), "dummy")
|
||||
self.assertEqual(StageSerializer().get_verbose_name(obj), "Dummy Stage")
|
||||
|
||||
def test_api_viewset(self):
|
||||
"""Test that stage serializer returns the correct type"""
|
||||
dummy = DummyStage.objects.create()
|
||||
self.assertIn(dummy, StageViewSet().get_queryset())
|
@ -17,8 +17,8 @@ from django.views.decorators.clickjacking import xframe_options_sameorigin
|
||||
from django.views.generic import TemplateView, View
|
||||
from structlog import get_logger
|
||||
|
||||
from authentik.audit.models import cleanse_dict
|
||||
from authentik.core.models import USER_ATTRIBUTE_DEBUG
|
||||
from authentik.events.models import cleanse_dict
|
||||
from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException
|
||||
from authentik.flows.models import ConfigurableStage, Flow, FlowDesignation, Stage
|
||||
from authentik.flows.planner import (
|
||||
|
@ -80,11 +80,15 @@ class BaseEvaluator:
|
||||
span: Span
|
||||
span.set_data("expression", expression_source)
|
||||
param_keys = self._context.keys()
|
||||
ast_obj = compile(
|
||||
self.wrap_expression(expression_source, param_keys),
|
||||
self._filename,
|
||||
"exec",
|
||||
)
|
||||
try:
|
||||
ast_obj = compile(
|
||||
self.wrap_expression(expression_source, param_keys),
|
||||
self._filename,
|
||||
"exec",
|
||||
)
|
||||
except (SyntaxError, ValueError) as exc:
|
||||
self.handle_error(exc, expression_source)
|
||||
raise exc
|
||||
try:
|
||||
_locals = self._context
|
||||
# Yes this is an exec, yes it is potentially bad. Since we limit what variables are
|
||||
@ -94,10 +98,15 @@ class BaseEvaluator:
|
||||
exec(ast_obj, self._globals, _locals) # nosec # noqa
|
||||
result = _locals["result"]
|
||||
except Exception as exc:
|
||||
LOGGER.warning("Expression error", exc=exc)
|
||||
raise
|
||||
self.handle_error(exc, expression_source)
|
||||
raise exc
|
||||
return result
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def handle_error(self, exc: Exception, expression_source: str): # pragma: no cover
|
||||
"""Exception Handler"""
|
||||
LOGGER.warning("Expression error", exc=exc)
|
||||
|
||||
def validate(self, expression: str) -> bool:
|
||||
"""Validate expression's syntax, raise ValidationError if Syntax is invalid"""
|
||||
param_keys = self._context.keys()
|
||||
|
@ -1,7 +1,11 @@
|
||||
"""Outpost forms"""
|
||||
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from kubernetes.client.configuration import Configuration
|
||||
from kubernetes.config.config_exception import ConfigException
|
||||
from kubernetes.config.kube_config import load_kube_config_from_dict
|
||||
|
||||
from authentik.admin.fields import CodeMirrorWidget, YAMLField
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
@ -71,6 +75,23 @@ class DockerServiceConnectionForm(forms.ModelForm):
|
||||
class KubernetesServiceConnectionForm(forms.ModelForm):
|
||||
"""Kubernetes service-connection form"""
|
||||
|
||||
def clean_kubeconfig(self):
|
||||
"""Validate kubeconfig by attempting to load it"""
|
||||
kubeconfig = self.cleaned_data["kubeconfig"]
|
||||
if kubeconfig == {}:
|
||||
if not self.cleaned_data["local"]:
|
||||
raise ValidationError(
|
||||
_("You can only use an empty kubeconfig when local is enabled.")
|
||||
)
|
||||
# Empty kubeconfig is valid
|
||||
return kubeconfig
|
||||
config = Configuration()
|
||||
try:
|
||||
load_kube_config_from_dict(kubeconfig, client_configuration=config)
|
||||
except ConfigException:
|
||||
raise ValidationError(_("Invalid kubeconfig"))
|
||||
return kubeconfig
|
||||
|
||||
class Meta:
|
||||
|
||||
model = KubernetesServiceConnection
|
||||
|
21
authentik/outposts/migrations/0015_auto_20201224_1206.py
Normal file
21
authentik/outposts/migrations/0015_auto_20201224_1206.py
Normal file
@ -0,0 +1,21 @@
|
||||
# Generated by Django 3.1.4 on 2020-12-24 12:06
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_outposts", "0014_auto_20201213_1407"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="kubernetesserviceconnection",
|
||||
name="kubeconfig",
|
||||
field=models.JSONField(
|
||||
blank=True,
|
||||
help_text="Paste your kubeconfig here. authentik will automatically use the currently selected context.",
|
||||
),
|
||||
),
|
||||
]
|
@ -234,7 +234,8 @@ class KubernetesServiceConnection(OutpostServiceConnection):
|
||||
"Paste your kubeconfig here. authentik will automatically use "
|
||||
"the currently selected context."
|
||||
)
|
||||
)
|
||||
),
|
||||
blank=True,
|
||||
)
|
||||
|
||||
@property
|
||||
|
@ -47,6 +47,8 @@ class PolicyEngine:
|
||||
__cached_policies: List[PolicyResult]
|
||||
__processes: List[PolicyProcessInfo]
|
||||
|
||||
__expected_result_count: int
|
||||
|
||||
def __init__(
|
||||
self, pbm: PolicyBindingModel, user: User, request: HttpRequest = None
|
||||
):
|
||||
@ -59,6 +61,7 @@ class PolicyEngine:
|
||||
self.__cached_policies = []
|
||||
self.__processes = []
|
||||
self.use_cache = True
|
||||
self.__expected_result_count = 0
|
||||
|
||||
def _iter_bindings(self) -> Iterator[PolicyBinding]:
|
||||
"""Make sure all Policies are their respective classes"""
|
||||
@ -79,6 +82,8 @@ class PolicyEngine:
|
||||
span.set_data("pbm", self.__pbm)
|
||||
span.set_data("request", self.request)
|
||||
for binding in self._iter_bindings():
|
||||
self.__expected_result_count += 1
|
||||
|
||||
self._check_policy_type(binding.policy)
|
||||
key = cache_key(binding, self.request)
|
||||
cached_policy = cache.get(key, None)
|
||||
@ -112,10 +117,13 @@ class PolicyEngine:
|
||||
process_results: List[PolicyResult] = [
|
||||
x.result for x in self.__processes if x.result
|
||||
]
|
||||
all_results = list(process_results + self.__cached_policies)
|
||||
final_result = PolicyResult(False)
|
||||
final_result.messages = []
|
||||
final_result.source_results = list(process_results + self.__cached_policies)
|
||||
for result in process_results + self.__cached_policies:
|
||||
final_result.source_results = all_results
|
||||
if len(all_results) < self.__expected_result_count: # pragma: no cover
|
||||
raise AssertionError("Got less results than polices")
|
||||
for result in all_results:
|
||||
LOGGER.debug(
|
||||
"P_ENG: result", passing=result.passing, messages=result.messages
|
||||
)
|
||||
|
@ -1,16 +1,21 @@
|
||||
"""authentik expression policy evaluator"""
|
||||
from ipaddress import ip_address, ip_network
|
||||
from typing import List
|
||||
from traceback import format_tb
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
|
||||
from django.http import HttpRequest
|
||||
from structlog import get_logger
|
||||
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.events.utils import model_to_dict, sanitize_dict
|
||||
from authentik.flows.planner import PLAN_CONTEXT_SSO
|
||||
from authentik.lib.expression.evaluator import BaseEvaluator
|
||||
from authentik.lib.utils.http import get_client_ip
|
||||
from authentik.policies.types import PolicyRequest, PolicyResult
|
||||
|
||||
LOGGER = get_logger()
|
||||
if TYPE_CHECKING:
|
||||
from authentik.policies.expression.models import ExpressionPolicy
|
||||
|
||||
|
||||
class PolicyEvaluator(BaseEvaluator):
|
||||
@ -18,9 +23,12 @@ class PolicyEvaluator(BaseEvaluator):
|
||||
|
||||
_messages: List[str]
|
||||
|
||||
policy: Optional["ExpressionPolicy"] = None
|
||||
|
||||
def __init__(self, policy_name: str):
|
||||
super().__init__()
|
||||
self._messages = []
|
||||
self._context["ak_logger"] = get_logger(policy_name)
|
||||
self._context["ak_message"] = self.expr_func_message
|
||||
self._context["ip_address"] = ip_address
|
||||
self._context["ip_network"] = ip_network
|
||||
@ -45,15 +53,30 @@ class PolicyEvaluator(BaseEvaluator):
|
||||
self._context["ak_client_ip"] = ip_address(
|
||||
get_client_ip(request) or "255.255.255.255"
|
||||
)
|
||||
self._context["request"] = request
|
||||
self._context["http_request"] = request
|
||||
|
||||
def handle_error(self, exc: Exception, expression_source: str):
|
||||
"""Exception Handler"""
|
||||
error_string = "\n".join(format_tb(exc.__traceback__) + [str(exc)])
|
||||
event = Event.new(
|
||||
EventAction.POLICY_EXCEPTION,
|
||||
expression=expression_source,
|
||||
error=error_string,
|
||||
request=self._context["request"],
|
||||
)
|
||||
if self.policy:
|
||||
event.context["model"] = sanitize_dict(model_to_dict(self.policy))
|
||||
if "http_request" in self._context:
|
||||
event.from_http(self._context["http_request"])
|
||||
else:
|
||||
event.set_user(self._context["request"].user)
|
||||
event.save()
|
||||
|
||||
def evaluate(self, expression_source: str) -> PolicyResult:
|
||||
"""Parse and evaluate expression. Policy is expected to return a truthy object.
|
||||
Messages can be added using 'do ak_message()'."""
|
||||
try:
|
||||
result = super().evaluate(expression_source)
|
||||
except (ValueError, SyntaxError) as exc:
|
||||
return PolicyResult(False, str(exc))
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
LOGGER.warning("Expression error", exc=exc)
|
||||
return PolicyResult(False, str(exc))
|
||||
|
@ -31,11 +31,14 @@ class ExpressionPolicy(Policy):
|
||||
def passes(self, request: PolicyRequest) -> PolicyResult:
|
||||
"""Evaluate and render expression. Returns PolicyResult(false) on error."""
|
||||
evaluator = PolicyEvaluator(self.name)
|
||||
evaluator.policy = self
|
||||
evaluator.set_policy_request(request)
|
||||
return evaluator.evaluate(self.expression)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
PolicyEvaluator(self.name).validate(self.expression)
|
||||
evaluator = PolicyEvaluator(self.name)
|
||||
evaluator.policy = self
|
||||
evaluator.validate(self.expression)
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
|
@ -3,7 +3,9 @@ from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase
|
||||
from guardian.shortcuts import get_anonymous_user
|
||||
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.policies.expression.evaluator import PolicyEvaluator
|
||||
from authentik.policies.expression.models import ExpressionPolicy
|
||||
from authentik.policies.types import PolicyRequest
|
||||
|
||||
|
||||
@ -13,6 +15,14 @@ class TestEvaluator(TestCase):
|
||||
def setUp(self):
|
||||
self.request = PolicyRequest(user=get_anonymous_user())
|
||||
|
||||
def test_full(self):
|
||||
"""Test full with Policy instance"""
|
||||
policy = ExpressionPolicy(name="test", expression="return 'test'")
|
||||
policy.save()
|
||||
request = PolicyRequest(get_anonymous_user())
|
||||
result = policy.passes(request)
|
||||
self.assertTrue(result.passing)
|
||||
|
||||
def test_valid(self):
|
||||
"""test simple value expression"""
|
||||
template = "return True"
|
||||
@ -37,6 +47,12 @@ class TestEvaluator(TestCase):
|
||||
result = evaluator.evaluate(template)
|
||||
self.assertEqual(result.passing, False)
|
||||
self.assertEqual(result.messages, ("invalid syntax (test, line 3)",))
|
||||
self.assertTrue(
|
||||
Event.objects.filter(
|
||||
action=EventAction.POLICY_EXCEPTION,
|
||||
context__expression=template,
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_undefined(self):
|
||||
"""test undefined result"""
|
||||
@ -46,6 +62,12 @@ class TestEvaluator(TestCase):
|
||||
result = evaluator.evaluate(template)
|
||||
self.assertEqual(result.passing, False)
|
||||
self.assertEqual(result.messages, ("name 'foo' is not defined",))
|
||||
self.assertTrue(
|
||||
Event.objects.filter(
|
||||
action=EventAction.POLICY_EXCEPTION,
|
||||
context__expression=template,
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_validate(self):
|
||||
"""test validate"""
|
||||
|
@ -5,7 +5,7 @@ from django import forms
|
||||
from authentik.lib.widgets import GroupedModelChoiceField
|
||||
from authentik.policies.models import Policy, PolicyBinding, PolicyBindingModel
|
||||
|
||||
GENERAL_FIELDS = ["name"]
|
||||
GENERAL_FIELDS = ["name", "execution_logging"]
|
||||
GENERAL_SERIALIZER_FIELDS = ["pk", "name"]
|
||||
|
||||
|
||||
|
@ -0,0 +1,21 @@
|
||||
# Generated by Django 3.1.4 on 2020-12-15 09:41
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_policies", "0003_auto_20200908_1542"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="policy",
|
||||
name="execution_logging",
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
help_text="When this option is enabled, all executions of this policy will be logged. By default, only execution errors are logged.",
|
||||
),
|
||||
),
|
||||
]
|
@ -81,6 +81,16 @@ class Policy(SerializerModel, CreatedUpdatedModel):
|
||||
|
||||
name = models.TextField(blank=True, null=True)
|
||||
|
||||
execution_logging = models.BooleanField(
|
||||
default=False,
|
||||
help_text=_(
|
||||
(
|
||||
"When this option is enabled, all executions of this policy will be logged. "
|
||||
"By default, only execution errors are logged."
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
objects = InheritanceAutoManager()
|
||||
|
||||
@property
|
||||
|
@ -8,6 +8,7 @@ from sentry_sdk.hub import Hub
|
||||
from sentry_sdk.tracing import Span
|
||||
from structlog import get_logger
|
||||
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.policies.exceptions import PolicyException
|
||||
from authentik.policies.models import PolicyBinding
|
||||
from authentik.policies.types import PolicyRequest, PolicyResult
|
||||
@ -48,40 +49,48 @@ class PolicyProcess(Process):
|
||||
|
||||
def execute(self) -> PolicyResult:
|
||||
"""Run actual policy, returns result"""
|
||||
LOGGER.debug(
|
||||
"P_ENG(proc): Running policy",
|
||||
policy=self.binding.policy,
|
||||
user=self.request.user,
|
||||
process="PolicyProcess",
|
||||
)
|
||||
try:
|
||||
policy_result = self.binding.policy.passes(self.request)
|
||||
if self.binding.policy.execution_logging:
|
||||
event = Event.new(
|
||||
EventAction.POLICY_EXECUTION,
|
||||
request=self.request,
|
||||
result=policy_result,
|
||||
)
|
||||
event.set_user(self.request.user)
|
||||
event.save()
|
||||
except PolicyException as exc:
|
||||
LOGGER.debug("P_ENG(proc): error", exc=exc)
|
||||
policy_result = PolicyResult(False, str(exc))
|
||||
policy_result.source_policy = self.binding.policy
|
||||
# Invert result if policy.negate is set
|
||||
if self.binding.negate:
|
||||
policy_result.passing = not policy_result.passing
|
||||
LOGGER.debug(
|
||||
"P_ENG(proc): Finished",
|
||||
policy=self.binding.policy,
|
||||
result=policy_result,
|
||||
process="PolicyProcess",
|
||||
passing=policy_result.passing,
|
||||
user=self.request.user,
|
||||
)
|
||||
key = cache_key(self.binding, self.request)
|
||||
cache.set(key, policy_result)
|
||||
LOGGER.debug("P_ENG(proc): Cached policy evaluation", key=key)
|
||||
return policy_result
|
||||
|
||||
def run(self): # pragma: no cover
|
||||
"""Task wrapper to run policy checking"""
|
||||
with Hub.current.start_span(
|
||||
op="policy.process.execute",
|
||||
) as span:
|
||||
span: Span
|
||||
span.set_data("policy", self.binding.policy)
|
||||
span.set_data("request", self.request)
|
||||
LOGGER.debug(
|
||||
"P_ENG(proc): Running policy",
|
||||
policy=self.binding.policy,
|
||||
user=self.request.user,
|
||||
process="PolicyProcess",
|
||||
)
|
||||
try:
|
||||
policy_result = self.binding.policy.passes(self.request)
|
||||
except PolicyException as exc:
|
||||
LOGGER.debug("P_ENG(proc): error", exc=exc)
|
||||
policy_result = PolicyResult(False, str(exc))
|
||||
policy_result.source_policy = self.binding.policy
|
||||
# Invert result if policy.negate is set
|
||||
if self.binding.negate:
|
||||
policy_result.passing = not policy_result.passing
|
||||
LOGGER.debug(
|
||||
"P_ENG(proc): Finished",
|
||||
policy=self.binding.policy,
|
||||
result=policy_result,
|
||||
process="PolicyProcess",
|
||||
passing=policy_result.passing,
|
||||
user=self.request.user,
|
||||
)
|
||||
key = cache_key(self.binding, self.request)
|
||||
cache.set(key, policy_result)
|
||||
LOGGER.debug("P_ENG(proc): Cached policy evaluation", key=key)
|
||||
return policy_result
|
||||
|
||||
def run(self):
|
||||
"""Task wrapper to run policy checking"""
|
||||
self.connection.send(self.execute())
|
||||
self.connection.send(self.execute())
|
||||
|
@ -7,13 +7,14 @@ from authentik.policies.dummy.models import DummyPolicy
|
||||
from authentik.policies.engine import PolicyEngine
|
||||
from authentik.policies.expression.models import ExpressionPolicy
|
||||
from authentik.policies.models import Policy, PolicyBinding, PolicyBindingModel
|
||||
from authentik.policies.tests.test_process import clear_policy_cache
|
||||
|
||||
|
||||
class TestPolicyEngine(TestCase):
|
||||
"""PolicyEngine tests"""
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
clear_policy_cache()
|
||||
self.user = User.objects.create_user(username="policyuser")
|
||||
self.policy_false = DummyPolicy.objects.create(
|
||||
result=False, wait_min=0, wait_max=1
|
||||
@ -34,6 +35,15 @@ class TestPolicyEngine(TestCase):
|
||||
self.assertEqual(result.passing, True)
|
||||
self.assertEqual(result.messages, ())
|
||||
|
||||
def test_engine_simple(self):
|
||||
"""Ensure simplest use-case"""
|
||||
pbm = PolicyBindingModel.objects.create()
|
||||
PolicyBinding.objects.create(target=pbm, policy=self.policy_true, order=0)
|
||||
engine = PolicyEngine(pbm, self.user)
|
||||
result = engine.build().result
|
||||
self.assertEqual(result.passing, True)
|
||||
self.assertEqual(result.messages, ("dummy",))
|
||||
|
||||
def test_engine(self):
|
||||
"""Ensure all policies passes (Mix of false and true -> false)"""
|
||||
pbm = PolicyBindingModel.objects.create()
|
||||
@ -75,10 +85,18 @@ class TestPolicyEngine(TestCase):
|
||||
def test_engine_cache(self):
|
||||
"""Ensure empty policy list passes"""
|
||||
pbm = PolicyBindingModel.objects.create()
|
||||
PolicyBinding.objects.create(target=pbm, policy=self.policy_false, order=0)
|
||||
binding = PolicyBinding.objects.create(
|
||||
target=pbm, policy=self.policy_false, order=0
|
||||
)
|
||||
engine = PolicyEngine(pbm, self.user)
|
||||
self.assertEqual(len(cache.keys("policy_*")), 0)
|
||||
self.assertEqual(
|
||||
len(cache.keys(f"policy_{binding.policy_binding_uuid.hex}*")), 0
|
||||
)
|
||||
self.assertEqual(engine.build().passing, False)
|
||||
self.assertEqual(len(cache.keys("policy_*")), 1)
|
||||
self.assertEqual(
|
||||
len(cache.keys(f"policy_{binding.policy_binding_uuid.hex}*")), 1
|
||||
)
|
||||
self.assertEqual(engine.build().passing, False)
|
||||
self.assertEqual(len(cache.keys("policy_*")), 1)
|
||||
self.assertEqual(
|
||||
len(cache.keys(f"policy_{binding.policy_binding_uuid.hex}*")), 1
|
||||
)
|
||||
|
105
authentik/policies/tests/test_process.py
Normal file
105
authentik/policies/tests/test_process.py
Normal file
@ -0,0 +1,105 @@
|
||||
"""policy process tests"""
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.policies.dummy.models import DummyPolicy
|
||||
from authentik.policies.expression.models import ExpressionPolicy
|
||||
from authentik.policies.models import Policy, PolicyBinding
|
||||
from authentik.policies.process import PolicyProcess
|
||||
from authentik.policies.types import PolicyRequest
|
||||
|
||||
|
||||
def clear_policy_cache():
|
||||
"""Ensure no policy-related keys are stil cached"""
|
||||
keys = cache.keys("policy_*")
|
||||
cache.delete(keys)
|
||||
|
||||
|
||||
class TestPolicyProcess(TestCase):
|
||||
"""Policy Process tests"""
|
||||
|
||||
def setUp(self):
|
||||
clear_policy_cache()
|
||||
self.user = User.objects.create_user(username="policyuser")
|
||||
|
||||
def test_invalid(self):
|
||||
"""Test Process with invalid arguments"""
|
||||
policy = DummyPolicy.objects.create(result=True, wait_min=0, wait_max=1)
|
||||
binding = PolicyBinding(policy=policy)
|
||||
with self.assertRaises(ValueError):
|
||||
PolicyProcess(binding, None, None) # type: ignore
|
||||
|
||||
def test_true(self):
|
||||
"""Test policy execution"""
|
||||
policy = DummyPolicy.objects.create(result=True, wait_min=0, wait_max=1)
|
||||
binding = PolicyBinding(policy=policy)
|
||||
|
||||
request = PolicyRequest(self.user)
|
||||
response = PolicyProcess(binding, request, None).execute()
|
||||
self.assertEqual(response.passing, True)
|
||||
self.assertEqual(response.messages, ("dummy",))
|
||||
|
||||
def test_false(self):
|
||||
"""Test policy execution"""
|
||||
policy = DummyPolicy.objects.create(result=False, wait_min=0, wait_max=1)
|
||||
binding = PolicyBinding(policy=policy)
|
||||
|
||||
request = PolicyRequest(self.user)
|
||||
response = PolicyProcess(binding, request, None).execute()
|
||||
self.assertEqual(response.passing, False)
|
||||
self.assertEqual(response.messages, ("dummy",))
|
||||
|
||||
def test_negate(self):
|
||||
"""Test policy execution"""
|
||||
policy = DummyPolicy.objects.create(result=False, wait_min=0, wait_max=1)
|
||||
binding = PolicyBinding(policy=policy, negate=True)
|
||||
|
||||
request = PolicyRequest(self.user)
|
||||
response = PolicyProcess(binding, request, None).execute()
|
||||
self.assertEqual(response.passing, True)
|
||||
self.assertEqual(response.messages, ("dummy",))
|
||||
|
||||
def test_exception(self):
|
||||
"""Test policy execution"""
|
||||
policy = Policy.objects.create()
|
||||
binding = PolicyBinding(policy=policy)
|
||||
|
||||
request = PolicyRequest(self.user)
|
||||
response = PolicyProcess(binding, request, None).execute()
|
||||
self.assertEqual(response.passing, False)
|
||||
|
||||
def test_execution_logging(self):
|
||||
"""Test policy execution creates event"""
|
||||
policy = DummyPolicy.objects.create(
|
||||
result=False, wait_min=0, wait_max=1, execution_logging=True
|
||||
)
|
||||
binding = PolicyBinding(policy=policy)
|
||||
|
||||
request = PolicyRequest(self.user)
|
||||
response = PolicyProcess(binding, request, None).execute()
|
||||
self.assertEqual(response.passing, False)
|
||||
self.assertEqual(response.messages, ("dummy",))
|
||||
|
||||
events = Event.objects.filter(
|
||||
action=EventAction.POLICY_EXECUTION,
|
||||
)
|
||||
self.assertTrue(events.exists())
|
||||
self.assertEqual(len(events), 1)
|
||||
event = events.first()
|
||||
self.assertEqual(event.context["result"]["passing"], False)
|
||||
self.assertEqual(event.context["result"]["messages"], ["dummy"])
|
||||
|
||||
def test_raises(self):
|
||||
"""Test policy that raises error"""
|
||||
policy_raises = ExpressionPolicy.objects.create(
|
||||
name="raises", expression="{{ 0/0 }}"
|
||||
)
|
||||
binding = PolicyBinding(policy=policy_raises)
|
||||
|
||||
request = PolicyRequest(self.user)
|
||||
response = PolicyProcess(binding, request, None).execute()
|
||||
self.assertEqual(response.passing, False)
|
||||
self.assertEqual(response.messages, ("division by zero",))
|
||||
# self.assert
|
@ -1,6 +1,7 @@
|
||||
"""policy structures"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
|
||||
|
||||
from django.db.models import Model
|
||||
@ -11,6 +12,7 @@ if TYPE_CHECKING:
|
||||
from authentik.policies.models import Policy
|
||||
|
||||
|
||||
@dataclass
|
||||
class PolicyRequest:
|
||||
"""Data-class to hold policy request data"""
|
||||
|
||||
@ -20,6 +22,7 @@ class PolicyRequest:
|
||||
context: Dict[str, str]
|
||||
|
||||
def __init__(self, user: User):
|
||||
super().__init__()
|
||||
self.user = user
|
||||
self.http_request = None
|
||||
self.obj = None
|
||||
@ -29,6 +32,7 @@ class PolicyRequest:
|
||||
return f"<PolicyRequest user={self.user}>"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PolicyResult:
|
||||
"""Small data-class to hold policy results"""
|
||||
|
||||
@ -39,6 +43,7 @@ class PolicyResult:
|
||||
source_results: Optional[List["PolicyResult"]]
|
||||
|
||||
def __init__(self, passing: bool, *messages: str):
|
||||
super().__init__()
|
||||
self.passing = passing
|
||||
self.messages = messages
|
||||
self.source_policy = None
|
||||
@ -49,5 +54,5 @@ class PolicyResult:
|
||||
|
||||
def __str__(self):
|
||||
if self.messages:
|
||||
return f"PolicyResult passing={self.passing} messages={self.messages}"
|
||||
return f"PolicyResult passing={self.passing}"
|
||||
return f"<PolicyResult passing={self.passing} messages={self.messages}>"
|
||||
return f"<PolicyResult passing={self.passing}>"
|
||||
|
@ -11,6 +11,7 @@ from structlog import get_logger
|
||||
|
||||
from authentik.core.models import Application, Provider, User
|
||||
from authentik.flows.views import SESSION_KEY_APPLICATION_PRE
|
||||
from authentik.lib.sentry import SentryIgnoredException
|
||||
from authentik.policies.engine import PolicyEngine
|
||||
from authentik.policies.http import AccessDeniedResponse
|
||||
from authentik.policies.types import PolicyResult
|
||||
@ -18,6 +19,17 @@ from authentik.policies.types import PolicyResult
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class RequestValidationError(SentryIgnoredException):
|
||||
"""Error raised in pre_permission_check, when a request is invalid."""
|
||||
|
||||
response: Optional[HttpResponse]
|
||||
|
||||
def __init__(self, response: Optional[HttpResponse] = None):
|
||||
super().__init__()
|
||||
if response:
|
||||
self.response = response
|
||||
|
||||
|
||||
class BaseMixin:
|
||||
"""Base Mixin class, used to annotate View Member variables"""
|
||||
|
||||
@ -31,6 +43,10 @@ class PolicyAccessView(AccessMixin, View):
|
||||
provider: Provider
|
||||
application: Application
|
||||
|
||||
def pre_permission_check(self):
|
||||
"""Optionally hook in before permission check to check if a request is valid.
|
||||
Can raise `RequestValidationError` to return a response."""
|
||||
|
||||
def resolve_provider_application(self):
|
||||
"""Resolve self.provider and self.application. *.DoesNotExist Exceptions cause a normal
|
||||
AccessDenied view to be shown. An Http404 exception
|
||||
@ -38,6 +54,12 @@ class PolicyAccessView(AccessMixin, View):
|
||||
raise NotImplementedError
|
||||
|
||||
def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
|
||||
try:
|
||||
self.pre_permission_check()
|
||||
except RequestValidationError as exc:
|
||||
if exc.response:
|
||||
return exc.response
|
||||
return self.handle_no_permission()
|
||||
try:
|
||||
self.resolve_provider_application()
|
||||
except (Application.DoesNotExist, Provider.DoesNotExist):
|
||||
@ -82,7 +104,7 @@ class PolicyAccessView(AccessMixin, View):
|
||||
policy_engine.build()
|
||||
result = policy_engine.result
|
||||
LOGGER.debug(
|
||||
"AccessMixin user_has_access",
|
||||
"PolicyAccessView user_has_access",
|
||||
user=user,
|
||||
app=self.application,
|
||||
result=result,
|
||||
|
@ -2,10 +2,11 @@
|
||||
from rest_framework.serializers import ModelSerializer
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.core.api.utils import MetaNameSerializer
|
||||
from authentik.providers.oauth2.models import OAuth2Provider, ScopeMapping
|
||||
|
||||
|
||||
class OAuth2ProviderSerializer(ModelSerializer):
|
||||
class OAuth2ProviderSerializer(ModelSerializer, MetaNameSerializer):
|
||||
"""OAuth2Provider Serializer"""
|
||||
|
||||
class Meta:
|
||||
@ -19,12 +20,15 @@ class OAuth2ProviderSerializer(ModelSerializer):
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"token_validity",
|
||||
"response_type",
|
||||
"include_claims_in_id_token",
|
||||
"jwt_alg",
|
||||
"rsa_key",
|
||||
"redirect_uris",
|
||||
"sub_mode",
|
||||
"property_mappings",
|
||||
"issuer_mode",
|
||||
"verbose_name",
|
||||
"verbose_name_plural",
|
||||
]
|
||||
|
||||
|
||||
|
@ -4,6 +4,7 @@ GRANT_TYPE_AUTHORIZATION_CODE = "authorization_code"
|
||||
GRANT_TYPE_REFRESH_TOKEN = "refresh_token" # nosec
|
||||
PROMPT_NONE = "none"
|
||||
PROMPT_CONSNET = "consent"
|
||||
PROMPT_LOGIN = "login"
|
||||
SCOPE_OPENID = "openid"
|
||||
SCOPE_OPENID_PROFILE = "profile"
|
||||
SCOPE_OPENID_EMAIL = "email"
|
||||
@ -16,3 +17,5 @@ SCOPE_GITHUB_USER_READ = "read:user"
|
||||
SCOPE_GITHUB_USER_EMAIL = "user:email"
|
||||
# Read info about teams
|
||||
SCOPE_GITHUB_ORG_READ = "read:org"
|
||||
|
||||
ACR_AUTHENTIK_DEFAULT = "goauthentik.io/providers/oauth2/default"
|
||||
|
@ -1,8 +1,11 @@
|
||||
"""OAuth errors"""
|
||||
from urllib.parse import quote
|
||||
|
||||
from authentik.lib.sentry import SentryIgnoredException
|
||||
from authentik.providers.oauth2.models import GrantTypes
|
||||
|
||||
class OAuth2Error(Exception):
|
||||
|
||||
class OAuth2Error(SentryIgnoredException):
|
||||
"""Base class for all OAuth2 Errors"""
|
||||
|
||||
error: str
|
||||
@ -96,27 +99,34 @@ class AuthorizeError(OAuth2Error):
|
||||
"the registration parameter",
|
||||
}
|
||||
|
||||
def __init__(self, redirect_uri, error, grant_type):
|
||||
def __init__(
|
||||
self,
|
||||
redirect_uri: str,
|
||||
error: str,
|
||||
grant_type: str,
|
||||
state: str,
|
||||
):
|
||||
super().__init__()
|
||||
self.error = error
|
||||
self.description = self._errors[error]
|
||||
self.redirect_uri = redirect_uri
|
||||
self.grant_type = grant_type
|
||||
self.state = state
|
||||
|
||||
def create_uri(self, redirect_uri: str, state: str) -> str:
|
||||
def create_uri(self) -> str:
|
||||
"""Get a redirect URI with the error message"""
|
||||
description = quote(str(self.description))
|
||||
|
||||
# See:
|
||||
# http://openid.net/specs/openid-connect-core-1_0.html#ImplicitAuthError
|
||||
hash_or_question = "#" if self.grant_type == "implicit" else "?"
|
||||
hash_or_question = "#" if self.grant_type == GrantTypes.IMPLICIT else "?"
|
||||
|
||||
uri = "{0}{1}error={2}&error_description={3}".format(
|
||||
redirect_uri, hash_or_question, self.error, description
|
||||
self.redirect_uri, hash_or_question, self.error, description
|
||||
)
|
||||
|
||||
# Add state if present.
|
||||
uri = uri + ("&state={0}".format(state) if state else "")
|
||||
uri = uri + ("&state={0}".format(self.state) if self.state else "")
|
||||
|
||||
return uri
|
||||
|
||||
|
@ -53,13 +53,14 @@ class OAuth2ProviderForm(forms.ModelForm):
|
||||
"client_type",
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"response_type",
|
||||
"token_validity",
|
||||
"jwt_alg",
|
||||
"property_mappings",
|
||||
"rsa_key",
|
||||
"redirect_uris",
|
||||
"sub_mode",
|
||||
"property_mappings",
|
||||
"include_claims_in_id_token",
|
||||
"issuer_mode",
|
||||
]
|
||||
widgets = {
|
||||
"name": forms.TextInput(),
|
||||
|
@ -27,10 +27,6 @@ class Migration(migrations.Migration):
|
||||
field=models.TextField(
|
||||
choices=[
|
||||
("code", "code (Authorization Code Flow)"),
|
||||
(
|
||||
"code_adfs",
|
||||
"code (ADFS Compatibility Mode, sends id_token as access_token)",
|
||||
),
|
||||
("id_token", "id_token (Implicit Flow)"),
|
||||
("id_token token", "id_token token (Implicit Flow)"),
|
||||
("code token", "code token (Hybrid Flow)"),
|
||||
|
@ -19,10 +19,6 @@ class Migration(migrations.Migration):
|
||||
field=models.TextField(
|
||||
choices=[
|
||||
("code", "code (Authorization Code Flow)"),
|
||||
(
|
||||
"code#adfs",
|
||||
"code (ADFS Compatibility Mode, sends id_token as access_token)",
|
||||
),
|
||||
("id_token", "id_token (Implicit Flow)"),
|
||||
("id_token token", "id_token token (Implicit Flow)"),
|
||||
("code token", "code token (Hybrid Flow)"),
|
||||
|
@ -0,0 +1,28 @@
|
||||
# Generated by Django 3.1.4 on 2020-12-27 13:54
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_providers_oauth2", "0007_auto_20201016_1107"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="oauth2provider",
|
||||
name="issuer_mode",
|
||||
field=models.TextField(
|
||||
choices=[
|
||||
("global", "Same identifier is used for all providers"),
|
||||
(
|
||||
"per_provider",
|
||||
"Each provider has a different issuer, based on the application slug.",
|
||||
),
|
||||
],
|
||||
default="per_provider",
|
||||
help_text="Configure how the issuer field of the ID Token should be filled.",
|
||||
),
|
||||
),
|
||||
]
|
@ -0,0 +1,17 @@
|
||||
# Generated by Django 3.1.4 on 2020-12-27 16:32
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_providers_oauth2", "0008_oauth2provider_issuer_mode"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="oauth2provider",
|
||||
name="response_type",
|
||||
),
|
||||
]
|
@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.1.4 on 2020-12-27 18:04
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_providers_oauth2", "0009_remove_oauth2provider_response_type"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="refreshtoken",
|
||||
name="access_token",
|
||||
field=models.TextField(verbose_name="Access Token"),
|
||||
),
|
||||
]
|
@ -9,6 +9,7 @@ from typing import Any, Dict, List, Optional, Type
|
||||
from urllib.parse import urlparse
|
||||
from uuid import uuid4
|
||||
|
||||
from dacite import from_dict
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.forms import ModelForm
|
||||
@ -22,9 +23,12 @@ from rest_framework.serializers import Serializer
|
||||
|
||||
from authentik.core.models import ExpiringModel, PropertyMapping, Provider, User
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.events.utils import get_user
|
||||
from authentik.lib.utils.template import render_to_string
|
||||
from authentik.lib.utils.time import timedelta_from_string, timedelta_string_validator
|
||||
from authentik.providers.oauth2.apps import AuthentikProviderOAuth2Config
|
||||
from authentik.providers.oauth2.constants import ACR_AUTHENTIK_DEFAULT
|
||||
from authentik.providers.oauth2.generators import (
|
||||
generate_client_id,
|
||||
generate_client_secret,
|
||||
@ -67,14 +71,19 @@ class SubModes(models.TextChoices):
|
||||
)
|
||||
|
||||
|
||||
class IssuerMode(models.TextChoices):
|
||||
"""Configure how the `iss` field is created."""
|
||||
|
||||
GLOBAL = "global", _("Same identifier is used for all providers")
|
||||
PER_PROVIDER = "per_provider", _(
|
||||
"Each provider has a different issuer, based on the application slug."
|
||||
)
|
||||
|
||||
|
||||
class ResponseTypes(models.TextChoices):
|
||||
"""Response Type required by the client."""
|
||||
|
||||
CODE = "code", _("code (Authorization Code Flow)")
|
||||
CODE_ADFS = (
|
||||
"code#adfs",
|
||||
_("code (ADFS Compatibility Mode, sends id_token as access_token)"),
|
||||
)
|
||||
ID_TOKEN = "id_token", _("id_token (Implicit Flow)")
|
||||
ID_TOKEN_TOKEN = "id_token token", _("id_token token (Implicit Flow)")
|
||||
CODE_TOKEN = "code token", _("code token (Hybrid Flow)")
|
||||
@ -140,11 +149,6 @@ class OAuth2Provider(Provider):
|
||||
verbose_name=_("Client Secret"),
|
||||
default=generate_client_secret,
|
||||
)
|
||||
response_type = models.TextField(
|
||||
choices=ResponseTypes.choices,
|
||||
default=ResponseTypes.CODE,
|
||||
help_text=_(ResponseTypes.__doc__),
|
||||
)
|
||||
jwt_alg = models.CharField(
|
||||
max_length=10,
|
||||
choices=JWTAlgorithms.choices,
|
||||
@ -190,6 +194,13 @@ class OAuth2Provider(Provider):
|
||||
)
|
||||
),
|
||||
)
|
||||
issuer_mode = models.TextField(
|
||||
choices=IssuerMode.choices,
|
||||
default=IssuerMode.PER_PROVIDER,
|
||||
help_text=_(
|
||||
("Configure how the issuer field of the ID Token should be filled.")
|
||||
),
|
||||
)
|
||||
|
||||
rsa_key = models.ForeignKey(
|
||||
CertificateKeyPair,
|
||||
@ -203,19 +214,17 @@ class OAuth2Provider(Provider):
|
||||
)
|
||||
|
||||
def create_refresh_token(
|
||||
self, user: User, scope: List[str], id_token: Optional["IDToken"] = None
|
||||
self, user: User, scope: List[str], request: HttpRequest
|
||||
) -> "RefreshToken":
|
||||
"""Create and populate a RefreshToken object."""
|
||||
token = RefreshToken(
|
||||
user=user,
|
||||
provider=self,
|
||||
access_token=uuid4().hex,
|
||||
refresh_token=uuid4().hex,
|
||||
expires=timezone.now() + timedelta_from_string(self.token_validity),
|
||||
scope=scope,
|
||||
)
|
||||
if id_token:
|
||||
token.id_token = id_token
|
||||
token.access_token = token.create_access_token(user, request)
|
||||
return token
|
||||
|
||||
def get_jwt_keys(self) -> List[Key]:
|
||||
@ -227,6 +236,11 @@ class OAuth2Provider(Provider):
|
||||
# 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:
|
||||
@ -246,6 +260,8 @@ class OAuth2Provider(Provider):
|
||||
|
||||
def get_issuer(self, request: HttpRequest) -> Optional[str]:
|
||||
"""Get issuer, based on request"""
|
||||
if self.issuer_mode == IssuerMode.GLOBAL:
|
||||
return request.build_absolute_uri("/")
|
||||
try:
|
||||
mountpoint = AuthentikProviderOAuth2Config.mountpoints[
|
||||
"authentik.providers.oauth2.urls"
|
||||
@ -365,6 +381,18 @@ class AuthorizationCode(ExpiringModel, BaseGrantModel):
|
||||
max_length=255, null=True, verbose_name=_("Code Challenge Method")
|
||||
)
|
||||
|
||||
@property
|
||||
def c_hash(self):
|
||||
"""https://openid.net/specs/openid-connect-core-1_0.html#IDToken"""
|
||||
hashed_code = sha256(self.code.encode("ascii")).hexdigest().encode("ascii")
|
||||
return (
|
||||
base64.urlsafe_b64encode(
|
||||
binascii.unhexlify(hashed_code[: len(hashed_code) // 2])
|
||||
)
|
||||
.rstrip(b"=")
|
||||
.decode("ascii")
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Authorization Code")
|
||||
verbose_name_plural = _("Authorization Codes")
|
||||
@ -390,20 +418,15 @@ class IDToken:
|
||||
exp: Optional[int] = None
|
||||
iat: Optional[int] = None
|
||||
auth_time: Optional[int] = None
|
||||
acr: Optional[str] = ACR_AUTHENTIK_DEFAULT
|
||||
|
||||
c_hash: Optional[str] = None
|
||||
|
||||
nonce: Optional[str] = None
|
||||
at_hash: Optional[str] = None
|
||||
|
||||
claims: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data: Dict[str, Any]) -> "IDToken":
|
||||
"""Reconstruct ID Token from json dictionary"""
|
||||
token = IDToken()
|
||||
for key, value in data.items():
|
||||
setattr(token, key, value)
|
||||
return token
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert dataclass to dict, and update with keys from `claims`"""
|
||||
dic = asdict(self)
|
||||
@ -415,9 +438,7 @@ class IDToken:
|
||||
class RefreshToken(ExpiringModel, BaseGrantModel):
|
||||
"""OAuth2 Refresh Token"""
|
||||
|
||||
access_token = models.CharField(
|
||||
max_length=255, unique=True, verbose_name=_("Access Token")
|
||||
)
|
||||
access_token = models.TextField(verbose_name=_("Access Token"))
|
||||
refresh_token = models.CharField(
|
||||
max_length=255, unique=True, verbose_name=_("Refresh Token")
|
||||
)
|
||||
@ -432,7 +453,7 @@ class RefreshToken(ExpiringModel, BaseGrantModel):
|
||||
"""Load ID Token from json"""
|
||||
if self._id_token:
|
||||
raw_token = json.loads(self._id_token)
|
||||
return IDToken.from_dict(raw_token)
|
||||
return from_dict(IDToken, raw_token)
|
||||
return IDToken()
|
||||
|
||||
@id_token.setter
|
||||
@ -456,6 +477,13 @@ class RefreshToken(ExpiringModel, BaseGrantModel):
|
||||
.decode("ascii")
|
||||
)
|
||||
|
||||
def create_access_token(self, user: User, request: HttpRequest) -> str:
|
||||
"""Create access token with a similar format as Okta, Keycloak, ADFS"""
|
||||
token = self.create_id_token(user, request).to_dict()
|
||||
token["cid"] = self.provider.client_id
|
||||
token["uid"] = uuid4().hex
|
||||
return self.provider.encode(token)
|
||||
|
||||
def create_id_token(self, user: User, request: HttpRequest) -> IDToken:
|
||||
"""Creates the id_token.
|
||||
See: http://openid.net/specs/openid-connect-core-1_0.html#IDToken"""
|
||||
@ -482,8 +510,11 @@ class RefreshToken(ExpiringModel, BaseGrantModel):
|
||||
exp_time = int(
|
||||
now + timedelta_from_string(self.provider.token_validity).seconds
|
||||
)
|
||||
user_auth_time = user.last_login or user.date_joined
|
||||
auth_time = int(dateformat.format(user_auth_time, "U"))
|
||||
# We use the timestamp of the user's last successful login (EventAction.LOGIN) for auth_time
|
||||
auth_event = Event.objects.filter(
|
||||
action=EventAction.LOGIN, user=get_user(user)
|
||||
).latest("created")
|
||||
auth_time = int(dateformat.format(auth_event.created, "U"))
|
||||
|
||||
token = IDToken(
|
||||
iss=self.provider.get_issuer(request),
|
||||
|
@ -6,7 +6,6 @@ from typing import List, Optional, Tuple
|
||||
|
||||
from django.http import HttpRequest, HttpResponse, JsonResponse
|
||||
from django.utils.cache import patch_vary_headers
|
||||
from jwkest.jwt import JWT
|
||||
from structlog import get_logger
|
||||
|
||||
from authentik.providers.oauth2.errors import BearerTokenError
|
||||
@ -140,17 +139,3 @@ def protected_resource_view(scopes: List[str]):
|
||||
return view_wrapper
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def client_id_from_id_token(id_token):
|
||||
"""
|
||||
Extracts the client id from a JSON Web Token (JWT).
|
||||
Returns a string or None.
|
||||
"""
|
||||
payload = JWT().unpack(id_token).payload()
|
||||
aud = payload.get("aud", None)
|
||||
if aud is None:
|
||||
return None
|
||||
if isinstance(aud, list):
|
||||
return aud[0]
|
||||
return aud
|
||||
|
@ -1,16 +1,19 @@
|
||||
"""authentik OAuth2 Authorization views"""
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import timedelta
|
||||
from typing import List, Optional, Set
|
||||
from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
|
||||
from uuid import uuid4
|
||||
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.http.response import Http404
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.utils import timezone
|
||||
from structlog import get_logger
|
||||
|
||||
from authentik.audit.models import Event, EventAction
|
||||
from authentik.core.models import Application
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.events.utils import get_user
|
||||
from authentik.flows.models import in_memory_stage
|
||||
from authentik.flows.planner import (
|
||||
PLAN_CONTEXT_APPLICATION,
|
||||
@ -23,9 +26,10 @@ from authentik.flows.views import SESSION_KEY_PLAN
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.lib.utils.urls import redirect_with_qs
|
||||
from authentik.lib.views import bad_request_message
|
||||
from authentik.policies.views import PolicyAccessView
|
||||
from authentik.policies.views import PolicyAccessView, RequestValidationError
|
||||
from authentik.providers.oauth2.constants import (
|
||||
PROMPT_CONSNET,
|
||||
PROMPT_LOGIN,
|
||||
PROMPT_NONE,
|
||||
SCOPE_OPENID,
|
||||
)
|
||||
@ -52,11 +56,13 @@ LOGGER = get_logger()
|
||||
|
||||
PLAN_CONTEXT_PARAMS = "params"
|
||||
PLAN_CONTEXT_SCOPE_DESCRIPTIONS = "scope_descriptions"
|
||||
SESSION_NEEDS_LOGIN = "authentik_oauth2_needs_login"
|
||||
|
||||
ALLOWED_PROMPT_PARAMS = {PROMPT_NONE, PROMPT_CONSNET}
|
||||
ALLOWED_PROMPT_PARAMS = {PROMPT_NONE, PROMPT_CONSNET, PROMPT_LOGIN}
|
||||
|
||||
|
||||
@dataclass
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
class OAuthAuthorizationParams:
|
||||
"""Parameteres required to authorize an OAuth Client"""
|
||||
|
||||
@ -65,12 +71,16 @@ class OAuthAuthorizationParams:
|
||||
response_type: str
|
||||
scope: List[str]
|
||||
state: str
|
||||
nonce: str
|
||||
nonce: Optional[str]
|
||||
prompt: Set[str]
|
||||
grant_type: str
|
||||
|
||||
provider: OAuth2Provider = field(default_factory=OAuth2Provider)
|
||||
|
||||
request: Optional[str] = None
|
||||
|
||||
max_age: Optional[int] = None
|
||||
|
||||
code_challenge: Optional[str] = None
|
||||
code_challenge_method: Optional[str] = None
|
||||
|
||||
@ -85,16 +95,17 @@ class OAuthAuthorizationParams:
|
||||
# Because in this endpoint we handle both GET
|
||||
# and POST request.
|
||||
query_dict = request.POST if request.method == "POST" else request.GET
|
||||
state = query_dict.get("state")
|
||||
redirect_uri = query_dict.get("redirect_uri", "")
|
||||
|
||||
response_type = query_dict.get("response_type", "")
|
||||
grant_type = None
|
||||
# Determine which flow to use.
|
||||
if response_type in [ResponseTypes.CODE, ResponseTypes.CODE_ADFS]:
|
||||
if response_type in [ResponseTypes.CODE]:
|
||||
grant_type = GrantTypes.AUTHORIZATION_CODE
|
||||
elif response_type in [
|
||||
ResponseTypes.ID_TOKEN,
|
||||
ResponseTypes.ID_TOKEN_TOKEN,
|
||||
ResponseTypes.CODE_TOKEN,
|
||||
]:
|
||||
grant_type = GrantTypes.IMPLICIT
|
||||
elif response_type in [
|
||||
@ -107,23 +118,22 @@ class OAuthAuthorizationParams:
|
||||
# Grant type validation.
|
||||
if not grant_type:
|
||||
LOGGER.warning("Invalid response type", type=response_type)
|
||||
raise AuthorizeError(
|
||||
query_dict.get("redirect_uri", ""),
|
||||
"unsupported_response_type",
|
||||
grant_type,
|
||||
)
|
||||
raise AuthorizeError(redirect_uri, "unsupported_response_type", "", state)
|
||||
|
||||
max_age = query_dict.get("max_age")
|
||||
return OAuthAuthorizationParams(
|
||||
client_id=query_dict.get("client_id", ""),
|
||||
redirect_uri=query_dict.get("redirect_uri", ""),
|
||||
redirect_uri=redirect_uri,
|
||||
response_type=response_type,
|
||||
grant_type=grant_type,
|
||||
scope=query_dict.get("scope", "").split(),
|
||||
state=query_dict.get("state", ""),
|
||||
nonce=query_dict.get("nonce", ""),
|
||||
state=state,
|
||||
nonce=query_dict.get("nonce"),
|
||||
prompt=ALLOWED_PROMPT_PARAMS.intersection(
|
||||
set(query_dict.get("prompt", "").split())
|
||||
),
|
||||
request=query_dict.get("request", None),
|
||||
max_age=int(max_age) if max_age else None,
|
||||
code_challenge=query_dict.get("code_challenge"),
|
||||
code_challenge_method=query_dict.get("code_challenge_method"),
|
||||
)
|
||||
@ -136,15 +146,26 @@ class OAuthAuthorizationParams:
|
||||
except OAuth2Provider.DoesNotExist:
|
||||
LOGGER.warning("Invalid client identifier", client_id=self.client_id)
|
||||
raise ClientIdError()
|
||||
is_open_id = SCOPE_OPENID in self.scope
|
||||
self.check_redirect_uri()
|
||||
self.check_scope()
|
||||
self.check_nonce()
|
||||
self.check_code_challenge()
|
||||
|
||||
# Redirect URI validation.
|
||||
def check_redirect_uri(self):
|
||||
"""Redirect URI validation."""
|
||||
if not self.redirect_uri:
|
||||
LOGGER.warning("Missing redirect uri.")
|
||||
raise RedirectUriError()
|
||||
if self.redirect_uri.lower() not in [
|
||||
x.lower() for x in self.provider.redirect_uris.split()
|
||||
]:
|
||||
Event.new(
|
||||
EventAction.CONFIGURATION_ERROR,
|
||||
provider=self.provider,
|
||||
message="Invalid redirect URI was used.",
|
||||
client_used=self.redirect_uri,
|
||||
configured=self.provider.redirect_uris.split(),
|
||||
).save()
|
||||
LOGGER.warning(
|
||||
"Invalid redirect uri",
|
||||
redirect_uri=self.redirect_uri,
|
||||
@ -152,34 +173,41 @@ class OAuthAuthorizationParams:
|
||||
)
|
||||
raise RedirectUriError()
|
||||
|
||||
if not is_open_id and (
|
||||
if self.request:
|
||||
raise AuthorizeError(
|
||||
self.redirect_uri, "request_not_supported", self.grant_type, self.state
|
||||
)
|
||||
|
||||
def check_scope(self):
|
||||
"""Ensure openid scope is set in Hybrid flows, or when requesting an id_token"""
|
||||
if SCOPE_OPENID not in self.scope and (
|
||||
self.grant_type == GrantTypes.HYBRID
|
||||
or self.response_type
|
||||
in [ResponseTypes.ID_TOKEN, ResponseTypes.ID_TOKEN_TOKEN]
|
||||
):
|
||||
LOGGER.warning("Missing 'openid' scope.")
|
||||
raise AuthorizeError(self.redirect_uri, "invalid_scope", self.grant_type)
|
||||
raise AuthorizeError(
|
||||
self.redirect_uri, "invalid_scope", self.grant_type, self.state
|
||||
)
|
||||
|
||||
# Nonce parameter validation.
|
||||
if is_open_id and self.grant_type == GrantTypes.IMPLICIT and not self.nonce:
|
||||
raise AuthorizeError(self.redirect_uri, "invalid_request", self.grant_type)
|
||||
|
||||
# Response type parameter validation.
|
||||
if is_open_id:
|
||||
actual_response_type = self.provider.response_type
|
||||
if "#" in self.provider.response_type:
|
||||
hash_index = actual_response_type.index("#")
|
||||
actual_response_type = actual_response_type[:hash_index]
|
||||
if self.response_type != actual_response_type:
|
||||
def check_nonce(self):
|
||||
"""Nonce parameter validation."""
|
||||
if not self.nonce:
|
||||
self.nonce = self.state
|
||||
LOGGER.warning("Using state as nonce for OpenID Request")
|
||||
if not self.nonce:
|
||||
if SCOPE_OPENID in self.scope:
|
||||
LOGGER.warning("Missing nonce for OpenID Request")
|
||||
raise AuthorizeError(
|
||||
self.redirect_uri, "invalid_request", self.grant_type
|
||||
self.redirect_uri, "invalid_request", self.grant_type, self.state
|
||||
)
|
||||
|
||||
# PKCE validation of the transformation method.
|
||||
def check_code_challenge(self):
|
||||
"""PKCE validation of the transformation method."""
|
||||
if self.code_challenge:
|
||||
if not (self.code_challenge_method in ["plain", "S256"]):
|
||||
raise AuthorizeError(
|
||||
self.redirect_uri, "invalid_request", self.grant_type
|
||||
self.redirect_uri, "invalid_request", self.grant_type, self.state
|
||||
)
|
||||
|
||||
def create_code(self, request: HttpRequest) -> AuthorizationCode:
|
||||
@ -225,6 +253,7 @@ class OAuthFulfillmentStage(StageView):
|
||||
self.params.redirect_uri,
|
||||
"consent_required",
|
||||
self.params.grant_type,
|
||||
self.params.state,
|
||||
)
|
||||
Event.new(
|
||||
EventAction.AUTHORIZE_APPLICATION,
|
||||
@ -238,14 +267,12 @@ class OAuthFulfillmentStage(StageView):
|
||||
return bad_request_message(request, error.description, title=error.error)
|
||||
except AuthorizeError as error:
|
||||
self.executor.stage_invalid()
|
||||
uri = error.create_uri(self.params.redirect_uri, self.params.state)
|
||||
return redirect(uri)
|
||||
return redirect(error.create_uri())
|
||||
|
||||
def create_response_uri(self) -> str:
|
||||
"""Create a final Response URI the user is redirected to."""
|
||||
uri = urlsplit(self.params.redirect_uri)
|
||||
query_params = parse_qs(uri.query)
|
||||
query_fragment = {}
|
||||
|
||||
try:
|
||||
code = None
|
||||
@ -262,75 +289,117 @@ class OAuthFulfillmentStage(StageView):
|
||||
query_params["state"] = [
|
||||
str(self.params.state) if self.params.state else ""
|
||||
]
|
||||
elif self.params.grant_type in [GrantTypes.IMPLICIT, GrantTypes.HYBRID]:
|
||||
token = self.provider.create_refresh_token(
|
||||
user=self.request.user,
|
||||
scope=self.params.scope,
|
||||
|
||||
uri = uri._replace(query=urlencode(query_params, doseq=True))
|
||||
return urlunsplit(uri)
|
||||
if self.params.grant_type in [GrantTypes.IMPLICIT, GrantTypes.HYBRID]:
|
||||
query_fragment = self.create_implicit_response(code)
|
||||
|
||||
uri = uri._replace(
|
||||
fragment=uri.fragment + urlencode(query_fragment, doseq=True),
|
||||
)
|
||||
|
||||
# Check if response_type must include access_token in the response.
|
||||
if self.params.response_type in [
|
||||
ResponseTypes.ID_TOKEN_TOKEN,
|
||||
ResponseTypes.CODE_ID_TOKEN_TOKEN,
|
||||
ResponseTypes.ID_TOKEN,
|
||||
ResponseTypes.CODE_TOKEN,
|
||||
]:
|
||||
query_fragment["access_token"] = token.access_token
|
||||
|
||||
# We don't need id_token if it's an OAuth2 request.
|
||||
if SCOPE_OPENID in self.params.scope:
|
||||
id_token = token.create_id_token(
|
||||
user=self.request.user,
|
||||
request=self.request,
|
||||
)
|
||||
id_token.nonce = self.params.nonce
|
||||
|
||||
# Include at_hash when access_token is being returned.
|
||||
if "access_token" in query_fragment:
|
||||
id_token.at_hash = token.at_hash
|
||||
|
||||
# Check if response_type must include id_token in the response.
|
||||
if self.params.response_type in [
|
||||
ResponseTypes.ID_TOKEN,
|
||||
ResponseTypes.ID_TOKEN_TOKEN,
|
||||
ResponseTypes.CODE_ID_TOKEN,
|
||||
ResponseTypes.CODE_ID_TOKEN_TOKEN,
|
||||
]:
|
||||
query_fragment["id_token"] = self.provider.encode(
|
||||
id_token.to_dict()
|
||||
)
|
||||
token.id_token = id_token
|
||||
|
||||
# Store the token.
|
||||
token.save()
|
||||
|
||||
# Code parameter must be present if it's Hybrid Flow.
|
||||
if self.params.grant_type == GrantTypes.HYBRID:
|
||||
query_fragment["code"] = code.code
|
||||
|
||||
query_fragment["token_type"] = "bearer"
|
||||
query_fragment["expires_in"] = timedelta_from_string(
|
||||
self.provider.token_validity
|
||||
).seconds
|
||||
query_fragment["state"] = self.params.state if self.params.state else ""
|
||||
|
||||
return urlunsplit(uri)
|
||||
raise OAuth2Error()
|
||||
except OAuth2Error as error:
|
||||
LOGGER.exception("Error when trying to create response uri", error=error)
|
||||
raise AuthorizeError(
|
||||
self.params.redirect_uri, "server_error", self.params.grant_type
|
||||
self.params.redirect_uri,
|
||||
"server_error",
|
||||
self.params.grant_type,
|
||||
self.params.state,
|
||||
)
|
||||
|
||||
uri = uri._replace(
|
||||
query=urlencode(query_params, doseq=True),
|
||||
fragment=uri.fragment + urlencode(query_fragment, doseq=True),
|
||||
def create_implicit_response(self, code: Optional[AuthorizationCode]) -> dict:
|
||||
"""Create implicit response's URL Fragment dictionary"""
|
||||
query_fragment = {}
|
||||
|
||||
token = self.provider.create_refresh_token(
|
||||
user=self.request.user,
|
||||
scope=self.params.scope,
|
||||
request=self.request,
|
||||
)
|
||||
|
||||
return urlunsplit(uri)
|
||||
# Check if response_type must include access_token in the response.
|
||||
if self.params.response_type in [
|
||||
ResponseTypes.ID_TOKEN_TOKEN,
|
||||
ResponseTypes.CODE_ID_TOKEN_TOKEN,
|
||||
ResponseTypes.ID_TOKEN,
|
||||
ResponseTypes.CODE_TOKEN,
|
||||
]:
|
||||
query_fragment["access_token"] = token.access_token
|
||||
|
||||
# We don't need id_token if it's an OAuth2 request.
|
||||
if SCOPE_OPENID in self.params.scope:
|
||||
id_token = token.create_id_token(
|
||||
user=self.request.user,
|
||||
request=self.request,
|
||||
)
|
||||
id_token.nonce = self.params.nonce
|
||||
|
||||
# Include at_hash when access_token is being returned.
|
||||
if "access_token" in query_fragment:
|
||||
id_token.at_hash = token.at_hash
|
||||
|
||||
if self.params.response_type in [
|
||||
ResponseTypes.CODE_ID_TOKEN,
|
||||
ResponseTypes.CODE_ID_TOKEN_TOKEN,
|
||||
]:
|
||||
id_token.c_hash = code.c_hash
|
||||
|
||||
# Check if response_type must include id_token in the response.
|
||||
if self.params.response_type in [
|
||||
ResponseTypes.ID_TOKEN,
|
||||
ResponseTypes.ID_TOKEN_TOKEN,
|
||||
ResponseTypes.CODE_ID_TOKEN,
|
||||
ResponseTypes.CODE_ID_TOKEN_TOKEN,
|
||||
]:
|
||||
query_fragment["id_token"] = self.provider.encode(id_token.to_dict())
|
||||
token.id_token = id_token
|
||||
|
||||
# Store the token.
|
||||
token.save()
|
||||
|
||||
# Code parameter must be present if it's Hybrid Flow.
|
||||
if self.params.grant_type == GrantTypes.HYBRID:
|
||||
query_fragment["code"] = code.code
|
||||
|
||||
query_fragment["token_type"] = "bearer"
|
||||
query_fragment["expires_in"] = timedelta_from_string(
|
||||
self.provider.token_validity
|
||||
).seconds
|
||||
query_fragment["state"] = self.params.state if self.params.state else ""
|
||||
|
||||
return query_fragment
|
||||
|
||||
|
||||
class AuthorizationFlowInitView(PolicyAccessView):
|
||||
"""OAuth2 Flow initializer, checks access to application and starts flow"""
|
||||
|
||||
params: OAuthAuthorizationParams
|
||||
|
||||
def pre_permission_check(self):
|
||||
"""Check prompt parameter before checking permission/authentication,
|
||||
see https://openid.net/specs/openid-connect-core-1_0.html#rfc.section.3.1.2.6"""
|
||||
try:
|
||||
self.params = OAuthAuthorizationParams.from_request(self.request)
|
||||
except AuthorizeError as error:
|
||||
raise RequestValidationError(redirect(error.create_uri()))
|
||||
except OAuth2Error as error:
|
||||
raise RequestValidationError(
|
||||
bad_request_message(self.request, error.description, title=error.error)
|
||||
)
|
||||
except OAuth2Provider.DoesNotExist:
|
||||
raise Http404
|
||||
if PROMPT_NONE in self.params.prompt and not self.request.user.is_authenticated:
|
||||
# When "prompt" is set to "none" but the user is not logged in, show an error message
|
||||
error = AuthorizeError(
|
||||
self.params.redirect_uri,
|
||||
"login_required",
|
||||
self.params.grant_type,
|
||||
self.params.state,
|
||||
)
|
||||
raise RequestValidationError(redirect(error.create_uri()))
|
||||
|
||||
def resolve_provider_application(self):
|
||||
client_id = self.request.GET.get("client_id")
|
||||
self.provider = get_object_or_404(OAuth2Provider, client_id=client_id)
|
||||
@ -338,13 +407,27 @@ class AuthorizationFlowInitView(PolicyAccessView):
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
"""Check access to application, start FlowPLanner, return to flow executor shell"""
|
||||
# Extract params so we can save them in the plan context
|
||||
try:
|
||||
params = OAuthAuthorizationParams.from_request(request)
|
||||
except (ClientIdError, RedirectUriError) as error:
|
||||
# pylint: disable=no-member
|
||||
return bad_request_message(request, error.description, title=error.error)
|
||||
"""Start FlowPLanner, return to flow executor shell"""
|
||||
# After we've checked permissions, and the user has access, check if we need
|
||||
# to re-authenticate the user
|
||||
if self.params.max_age:
|
||||
current_age: timedelta = (
|
||||
timezone.now()
|
||||
- Event.objects.filter(
|
||||
action=EventAction.LOGIN, user=get_user(self.request.user)
|
||||
)
|
||||
.latest("created")
|
||||
.created
|
||||
)
|
||||
if current_age.total_seconds() > self.params.max_age:
|
||||
return self.handle_no_permission()
|
||||
# If prompt=login, we need to re-authenticate the user regardless
|
||||
if (
|
||||
PROMPT_LOGIN in self.params.prompt
|
||||
and SESSION_NEEDS_LOGIN not in self.request.session
|
||||
):
|
||||
self.request.session[SESSION_NEEDS_LOGIN] = True
|
||||
return self.handle_no_permission()
|
||||
# Regardless, we start the planner and return to it
|
||||
planner = FlowPlanner(self.provider.authorization_flow)
|
||||
# planner.use_cache = False
|
||||
@ -355,9 +438,9 @@ class AuthorizationFlowInitView(PolicyAccessView):
|
||||
PLAN_CONTEXT_SSO: True,
|
||||
PLAN_CONTEXT_APPLICATION: self.application,
|
||||
# OAuth2 related params
|
||||
PLAN_CONTEXT_PARAMS: params,
|
||||
PLAN_CONTEXT_PARAMS: self.params,
|
||||
PLAN_CONTEXT_SCOPE_DESCRIPTIONS: UserInfoView().get_scope_descriptions(
|
||||
params.scope
|
||||
self.params.scope
|
||||
),
|
||||
# Consent related params
|
||||
PLAN_CONTEXT_CONSENT_TEMPLATE: "providers/oauth2/consent.html",
|
||||
@ -365,7 +448,7 @@ class AuthorizationFlowInitView(PolicyAccessView):
|
||||
)
|
||||
# OpenID clients can specify a `prompt` parameter, and if its set to consent we
|
||||
# need to inject a consent stage
|
||||
if PROMPT_CONSNET in params.prompt:
|
||||
if PROMPT_CONSNET in self.params.prompt:
|
||||
if not any([isinstance(x, ConsentStageView) for x in plan.stages]):
|
||||
# Plan does not have any consent stage, so we add an in-memory one
|
||||
stage = ConsentStage(
|
||||
|
@ -7,7 +7,18 @@ from django.views import View
|
||||
from structlog import get_logger
|
||||
|
||||
from authentik.core.models import Application
|
||||
from authentik.providers.oauth2.models import OAuth2Provider
|
||||
from authentik.providers.oauth2.constants import (
|
||||
ACR_AUTHENTIK_DEFAULT,
|
||||
GRANT_TYPE_AUTHORIZATION_CODE,
|
||||
GRANT_TYPE_REFRESH_TOKEN,
|
||||
SCOPE_OPENID,
|
||||
)
|
||||
from authentik.providers.oauth2.models import (
|
||||
GrantTypes,
|
||||
OAuth2Provider,
|
||||
ResponseTypes,
|
||||
ScopeMapping,
|
||||
)
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
@ -20,6 +31,13 @@ class ProviderInfoView(View):
|
||||
|
||||
def get_info(self, provider: OAuth2Provider) -> Dict[str, Any]:
|
||||
"""Get dictionary for OpenID Connect information"""
|
||||
scopes = list(
|
||||
ScopeMapping.objects.filter(provider=provider).values_list(
|
||||
"scope_name", flat=True
|
||||
)
|
||||
)
|
||||
if SCOPE_OPENID not in scopes:
|
||||
scopes.append(SCOPE_OPENID)
|
||||
return {
|
||||
"issuer": provider.get_issuer(self.request),
|
||||
"authorization_endpoint": self.request.build_absolute_uri(
|
||||
@ -40,13 +58,25 @@ class ProviderInfoView(View):
|
||||
"introspection_endpoint": self.request.build_absolute_uri(
|
||||
reverse("authentik_providers_oauth2:token-introspection")
|
||||
),
|
||||
"response_types_supported": [provider.response_type],
|
||||
"response_types_supported": [
|
||||
ResponseTypes.CODE,
|
||||
ResponseTypes.ID_TOKEN,
|
||||
ResponseTypes.ID_TOKEN_TOKEN,
|
||||
ResponseTypes.CODE_TOKEN,
|
||||
ResponseTypes.CODE_ID_TOKEN,
|
||||
ResponseTypes.CODE_ID_TOKEN_TOKEN,
|
||||
],
|
||||
"jwks_uri": self.request.build_absolute_uri(
|
||||
reverse(
|
||||
"authentik_providers_oauth2:jwks",
|
||||
kwargs={"application_slug": provider.application.slug},
|
||||
)
|
||||
),
|
||||
"grant_types_supported": [
|
||||
GRANT_TYPE_AUTHORIZATION_CODE,
|
||||
GRANT_TYPE_REFRESH_TOKEN,
|
||||
GrantTypes.IMPLICIT,
|
||||
],
|
||||
"id_token_signing_alg_values_supported": [provider.jwt_alg],
|
||||
# See: http://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes
|
||||
"subject_types_supported": ["public"],
|
||||
@ -54,6 +84,13 @@ class ProviderInfoView(View):
|
||||
"client_secret_post",
|
||||
"client_secret_basic",
|
||||
],
|
||||
"acr_values_supported": [ACR_AUTHENTIK_DEFAULT],
|
||||
"scopes_supported": scopes,
|
||||
# https://openid.net/specs/openid-connect-core-1_0.html#RequestObject
|
||||
"request_parameter_supported": False,
|
||||
# Because claims are dynamic and per-application, the only fixed Claim is "sub"
|
||||
"claims_supported": ["sub"],
|
||||
"claims_parameter_supported": False,
|
||||
}
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
|
@ -18,7 +18,6 @@ from authentik.providers.oauth2.models import (
|
||||
AuthorizationCode,
|
||||
OAuth2Provider,
|
||||
RefreshToken,
|
||||
ResponseTypes,
|
||||
)
|
||||
from authentik.providers.oauth2.utils import TokenResponse, extract_client_auth
|
||||
|
||||
@ -93,7 +92,10 @@ class TokenParams:
|
||||
self.refresh_token = RefreshToken.objects.get(
|
||||
refresh_token=raw_token, provider=self.provider
|
||||
)
|
||||
|
||||
# https://tools.ietf.org/html/rfc6749#section-6
|
||||
# Fallback to original token's scopes when none are given
|
||||
if self.scope == []:
|
||||
self.scope = self.refresh_token.scope
|
||||
except RefreshToken.DoesNotExist:
|
||||
LOGGER.warning(
|
||||
"Refresh token does not exist",
|
||||
@ -175,6 +177,7 @@ class TokenView(View):
|
||||
refresh_token = self.params.authorization_code.provider.create_refresh_token(
|
||||
user=self.params.authorization_code.user,
|
||||
scope=self.params.authorization_code.scope,
|
||||
request=self.request,
|
||||
)
|
||||
|
||||
if self.params.authorization_code.is_open_id:
|
||||
@ -202,13 +205,6 @@ class TokenView(View):
|
||||
"id_token": refresh_token.provider.encode(refresh_token.id_token.to_dict()),
|
||||
}
|
||||
|
||||
if self.params.provider.response_type == ResponseTypes.CODE_ADFS:
|
||||
# This seems to be expected by some OIDC Clients
|
||||
# namely VMware vCenter. This is not documented in any OpenID or OAuth2 Standard.
|
||||
# Maybe this should be a setting
|
||||
# in the future?
|
||||
response_dict["access_token"] = response_dict["id_token"]
|
||||
|
||||
return response_dict
|
||||
|
||||
def create_refresh_response_dic(self) -> Dict[str, Any]:
|
||||
@ -225,6 +221,7 @@ class TokenView(View):
|
||||
refresh_token: RefreshToken = provider.create_refresh_token(
|
||||
user=self.params.refresh_token.user,
|
||||
scope=self.params.scope,
|
||||
request=self.request,
|
||||
)
|
||||
|
||||
# If the Token has an id_token it's an Authentication request.
|
||||
@ -248,9 +245,7 @@ class TokenView(View):
|
||||
"expires_in": timedelta_from_string(
|
||||
refresh_token.provider.token_validity
|
||||
).seconds,
|
||||
"id_token": self.params.provider.encode(
|
||||
self.params.refresh_token.id_token.to_dict()
|
||||
),
|
||||
"id_token": self.params.provider.encode(refresh_token.id_token.to_dict()),
|
||||
}
|
||||
|
||||
return dic
|
||||
|
@ -6,6 +6,7 @@ from rest_framework.response import Response
|
||||
from rest_framework.serializers import ModelSerializer, Serializer
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.core.api.utils import MetaNameSerializer
|
||||
from authentik.providers.oauth2.views.provider import ProviderInfoView
|
||||
from authentik.providers.proxy.models import ProxyProvider
|
||||
|
||||
@ -33,7 +34,7 @@ class OpenIDConnectConfigurationSerializer(Serializer):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class ProxyProviderSerializer(ModelSerializer):
|
||||
class ProxyProviderSerializer(MetaNameSerializer, ModelSerializer):
|
||||
"""ProxyProvider Serializer"""
|
||||
|
||||
def create(self, validated_data):
|
||||
@ -60,6 +61,8 @@ class ProxyProviderSerializer(ModelSerializer):
|
||||
"basic_auth_enabled",
|
||||
"basic_auth_password_attribute",
|
||||
"basic_auth_user_attribute",
|
||||
"verbose_name",
|
||||
"verbose_name_plural",
|
||||
]
|
||||
|
||||
|
||||
|
@ -22,7 +22,6 @@ from authentik.providers.oauth2.models import (
|
||||
ClientTypes,
|
||||
JWTAlgorithms,
|
||||
OAuth2Provider,
|
||||
ResponseTypes,
|
||||
ScopeMapping,
|
||||
)
|
||||
|
||||
@ -127,7 +126,6 @@ class ProxyProvider(OutpostModel, OAuth2Provider):
|
||||
def set_oauth_defaults(self):
|
||||
"""Ensure all OAuth2-related settings are correct"""
|
||||
self.client_type = ClientTypes.CONFIDENTIAL
|
||||
self.response_type = ResponseTypes.CODE
|
||||
self.jwt_alg = JWTAlgorithms.RS256
|
||||
self.rsa_key = CertificateKeyPair.objects.first()
|
||||
scopes = ScopeMapping.objects.filter(
|
||||
|
@ -2,10 +2,11 @@
|
||||
from rest_framework.serializers import ModelSerializer
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.core.api.utils import MetaNameSerializer
|
||||
from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider
|
||||
|
||||
|
||||
class SAMLProviderSerializer(ModelSerializer):
|
||||
class SAMLProviderSerializer(ModelSerializer, MetaNameSerializer):
|
||||
"""SAMLProvider Serializer"""
|
||||
|
||||
class Meta:
|
||||
@ -25,6 +26,8 @@ class SAMLProviderSerializer(ModelSerializer):
|
||||
"signature_algorithm",
|
||||
"signing_kp",
|
||||
"verification_kp",
|
||||
"verbose_name",
|
||||
"verbose_name_plural",
|
||||
]
|
||||
|
||||
|
||||
|
@ -1,8 +1,13 @@
|
||||
"""authentik SAML IDP Forms"""
|
||||
|
||||
from xml.etree.ElementTree import ParseError # nosec
|
||||
|
||||
from defusedxml.ElementTree import fromstring
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import FileExtensionValidator
|
||||
from django.utils.html import mark_safe
|
||||
from django.utils.translation import gettext as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from authentik.admin.fields import CodeMirrorWidget
|
||||
from authentik.core.expression import PropertyMappingEvaluator
|
||||
@ -83,3 +88,22 @@ class SAMLPropertyMappingForm(forms.ModelForm):
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class SAMLProviderImportForm(forms.Form):
|
||||
"""Create a SAML Provider from SP Metadata."""
|
||||
|
||||
provider_name = forms.CharField()
|
||||
metadata = forms.FileField(
|
||||
validators=[FileExtensionValidator(allowed_extensions=["xml"])]
|
||||
)
|
||||
|
||||
def clean_metadata(self):
|
||||
"""Check if the flow is valid XML"""
|
||||
metadata = self.cleaned_data["metadata"].read()
|
||||
try:
|
||||
fromstring(metadata)
|
||||
except ParseError:
|
||||
raise ValidationError(_("Invalid XML Syntax"))
|
||||
self.cleaned_data["metadata"].seek(0)
|
||||
return self.cleaned_data["metadata"]
|
||||
|
@ -148,9 +148,9 @@ class SAMLProvider(Provider):
|
||||
|
||||
@property
|
||||
def serializer(self) -> Type[Serializer]:
|
||||
from authentik.providers.saml.api import SAMLPropertyMappingSerializer
|
||||
from authentik.providers.saml.api import SAMLProviderSerializer
|
||||
|
||||
return SAMLPropertyMappingSerializer
|
||||
return SAMLProviderSerializer
|
||||
|
||||
@property
|
||||
def form(self) -> Type[ModelForm]:
|
||||
|
143
authentik/providers/saml/processors/metadata_parser.py
Normal file
143
authentik/providers/saml/processors/metadata_parser.py
Normal file
@ -0,0 +1,143 @@
|
||||
"""SAML ServiceProvider Metadata Parser and dataclass"""
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import xmlsec
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.x509 import load_pem_x509_certificate
|
||||
from lxml import etree # nosec
|
||||
from structlog import get_logger
|
||||
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.providers.saml.models import SAMLBindings, SAMLProvider
|
||||
from authentik.providers.saml.utils.encoding import PEM_FOOTER, PEM_HEADER
|
||||
from authentik.sources.saml.processors.constants import (
|
||||
NS_MAP,
|
||||
NS_SAML_METADATA,
|
||||
SAML_BINDING_POST,
|
||||
SAML_BINDING_REDIRECT,
|
||||
)
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
def format_pem_certificate(unformatted_cert: str) -> str:
|
||||
"""Format single, inline certificate into PEM Format"""
|
||||
chunks, chunk_size = len(unformatted_cert), 64
|
||||
lines = [PEM_HEADER]
|
||||
for i in range(0, chunks, chunk_size):
|
||||
lines.append(unformatted_cert[i : i + chunk_size]) # noqa: E203
|
||||
lines.append(PEM_FOOTER)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServiceProviderMetadata:
|
||||
"""SP Metadata Dataclass"""
|
||||
|
||||
entity_id: str
|
||||
|
||||
acs_binding: str
|
||||
acs_location: str
|
||||
|
||||
auth_n_request_signed: bool
|
||||
assertion_signed: bool
|
||||
|
||||
signing_keypair: Optional[CertificateKeyPair] = None
|
||||
|
||||
def to_provider(self, name: str) -> SAMLProvider:
|
||||
"""Create a SAMLProvider instance from the details. `name` is required,
|
||||
as depending on the metadata CertificateKeypairs might have to be created."""
|
||||
provider = SAMLProvider(name=name)
|
||||
provider.issuer = self.entity_id
|
||||
provider.sp_binding = self.acs_binding
|
||||
provider.acs_url = self.acs_location
|
||||
if self.signing_keypair:
|
||||
self.signing_keypair.name = f"Provider {name} - SAML Signing Certificate"
|
||||
self.signing_keypair.save()
|
||||
provider.signing_kp = self.signing_keypair
|
||||
return provider
|
||||
|
||||
|
||||
class ServiceProviderMetadataParser:
|
||||
"""Service-Provider Metadata Parser"""
|
||||
|
||||
def get_signing_cert(self, root: etree.Element) -> Optional[CertificateKeyPair]:
|
||||
"""Extract X509Certificate from metadata, when given."""
|
||||
signing_certs = root.xpath(
|
||||
'//md:SPSSODescriptor/md:KeyDescriptor[@use="signing"]//ds:X509Certificate/text()',
|
||||
namespaces=NS_MAP,
|
||||
)
|
||||
if len(signing_certs) < 1:
|
||||
return None
|
||||
raw_cert = format_pem_certificate(signing_certs[0])
|
||||
# sanity check, make sure the certificate is valid.
|
||||
load_pem_x509_certificate(raw_cert.encode("utf-8"), default_backend())
|
||||
return CertificateKeyPair(
|
||||
certificate_data=raw_cert,
|
||||
)
|
||||
|
||||
def check_signature(self, root: etree.Element, keypair: CertificateKeyPair):
|
||||
"""If Metadata is signed, check validity of signature"""
|
||||
xmlsec.tree.add_ids(root, ["ID"])
|
||||
signature_nodes = root.xpath(
|
||||
"/md:EntityDescriptor/ds:Signature", namespaces=NS_MAP
|
||||
)
|
||||
if len(signature_nodes) != 1:
|
||||
# No Signature
|
||||
return
|
||||
|
||||
signature_node = signature_nodes[0]
|
||||
|
||||
if signature_node is not None:
|
||||
try:
|
||||
ctx = xmlsec.SignatureContext()
|
||||
key = xmlsec.Key.from_memory(
|
||||
keypair.certificate_data,
|
||||
xmlsec.constants.KeyDataFormatCertPem,
|
||||
None,
|
||||
)
|
||||
ctx.key = key
|
||||
ctx.verify(signature_node)
|
||||
except xmlsec.VerificationError as exc:
|
||||
raise ValueError("Failed to verify Metadata signature") from exc
|
||||
|
||||
def parse(self, raw_xml: str) -> ServiceProviderMetadata:
|
||||
"""Parse raw XML to ServiceProviderMetadata"""
|
||||
root = etree.fromstring(raw_xml) # nosec
|
||||
|
||||
entity_id = root.attrib["entityID"]
|
||||
sp_sso_descriptors = root.findall(f"{{{NS_SAML_METADATA}}}SPSSODescriptor")
|
||||
if len(sp_sso_descriptors) < 1:
|
||||
raise ValueError("no SPSSODescriptor objects found.")
|
||||
# For now we'll only look at the first descriptor.
|
||||
# Even if multiple descriptors exist, we can only configure one
|
||||
descriptor = sp_sso_descriptors[0]
|
||||
auth_n_request_signed = descriptor.attrib["AuthnRequestsSigned"]
|
||||
assertion_signed = descriptor.attrib["WantAssertionsSigned"]
|
||||
|
||||
acs_services = descriptor.findall(
|
||||
f"{{{NS_SAML_METADATA}}}AssertionConsumerService"
|
||||
)
|
||||
if len(acs_services) < 1:
|
||||
raise ValueError("No AssertionConsumerService found.")
|
||||
|
||||
acs_service = acs_services[0]
|
||||
acs_binding = {
|
||||
SAML_BINDING_REDIRECT: SAMLBindings.REDIRECT,
|
||||
SAML_BINDING_POST: SAMLBindings.POST,
|
||||
}[acs_service.attrib["Binding"]]
|
||||
acs_location = acs_service.attrib["Location"]
|
||||
|
||||
signing_keypair = self.get_signing_cert(root)
|
||||
if signing_keypair:
|
||||
self.check_signature(root, signing_keypair)
|
||||
|
||||
return ServiceProviderMetadata(
|
||||
entity_id=entity_id,
|
||||
acs_binding=acs_binding,
|
||||
acs_location=acs_location,
|
||||
auth_n_request_signed=auth_n_request_signed,
|
||||
assertion_signed=assertion_signed,
|
||||
signing_keypair=signing_keypair,
|
||||
)
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user